mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Use Hunspell for client-side spellchecking (#20286)
GitOrigin-RevId: c4d0d9e06fe0cc9d7cb7a058fd0768eb024e44f5
This commit is contained in:
parent
2d737810d4
commit
24c8629cd4
40 changed files with 5391 additions and 514 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
@ -8371,6 +8371,11 @@
|
||||||
"resolved": "services/contacts",
|
"resolved": "services/contacts",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@overleaf/dictionaries": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz",
|
||||||
|
"integrity": "sha512-p8QJhmQcZ33PEPe0Lqi7NfFkA9IbZNOvlPk3URKFTeb3qAHW+s5+U9qEnHp1s3xr7ZOgu6D5YPK0fRNOE9+zmg=="
|
||||||
|
},
|
||||||
"node_modules/@overleaf/docstore": {
|
"node_modules/@overleaf/docstore": {
|
||||||
"resolved": "services/docstore",
|
"resolved": "services/docstore",
|
||||||
"link": true
|
"link": true
|
||||||
|
@ -43751,6 +43756,7 @@
|
||||||
"@node-oauth/oauth2-server": "^5.1.0",
|
"@node-oauth/oauth2-server": "^5.1.0",
|
||||||
"@node-saml/passport-saml": "^4.0.4",
|
"@node-saml/passport-saml": "^4.0.4",
|
||||||
"@overleaf/access-token-encryptor": "*",
|
"@overleaf/access-token-encryptor": "*",
|
||||||
|
"@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz",
|
||||||
"@overleaf/fetch-utils": "*",
|
"@overleaf/fetch-utils": "*",
|
||||||
"@overleaf/logger": "*",
|
"@overleaf/logger": "*",
|
||||||
"@overleaf/metrics": "*",
|
"@overleaf/metrics": "*",
|
||||||
|
@ -51353,6 +51359,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@overleaf/dictionaries": {
|
||||||
|
"version": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz",
|
||||||
|
"integrity": "sha512-p8QJhmQcZ33PEPe0Lqi7NfFkA9IbZNOvlPk3URKFTeb3qAHW+s5+U9qEnHp1s3xr7ZOgu6D5YPK0fRNOE9+zmg=="
|
||||||
|
},
|
||||||
"@overleaf/docstore": {
|
"@overleaf/docstore": {
|
||||||
"version": "file:services/docstore",
|
"version": "file:services/docstore",
|
||||||
"requires": {
|
"requires": {
|
||||||
|
@ -52361,6 +52371,7 @@
|
||||||
"@opentelemetry/semantic-conventions": "^1.15.2",
|
"@opentelemetry/semantic-conventions": "^1.15.2",
|
||||||
"@overleaf/access-token-encryptor": "*",
|
"@overleaf/access-token-encryptor": "*",
|
||||||
"@overleaf/codemirror-tree-view": "^0.1.3",
|
"@overleaf/codemirror-tree-view": "^0.1.3",
|
||||||
|
"@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz",
|
||||||
"@overleaf/fetch-utils": "*",
|
"@overleaf/fetch-utils": "*",
|
||||||
"@overleaf/logger": "*",
|
"@overleaf/logger": "*",
|
||||||
"@overleaf/metrics": "*",
|
"@overleaf/metrics": "*",
|
||||||
|
|
|
@ -9,3 +9,4 @@ frontend/js/features/source-editor/lezer-latex/latex.mjs
|
||||||
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
|
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
|
||||||
frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
|
frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
|
||||||
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
|
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
|
||||||
|
frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs
|
||||||
|
|
|
@ -11,3 +11,4 @@ frontend/js/features/source-editor/lezer-latex/latex.mjs
|
||||||
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
|
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
|
||||||
frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
|
frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
|
||||||
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
|
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
|
||||||
|
frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs
|
||||||
|
|
|
@ -79,6 +79,7 @@ async function joinProject(req, res, next) {
|
||||||
// disable spellchecking for currently unsupported spell check languages
|
// disable spellchecking for currently unsupported spell check languages
|
||||||
// preserve the value in the db so they can use it again once we add back
|
// preserve the value in the db so they can use it again once we add back
|
||||||
// support.
|
// support.
|
||||||
|
// TODO: allow these if in client-side spell check split test
|
||||||
if (
|
if (
|
||||||
unsupportedSpellcheckLanguages.indexOf(project.spellCheckLanguage) !== -1
|
unsupportedSpellcheckLanguages.indexOf(project.spellCheckLanguage) !== -1
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -340,6 +340,7 @@ const _ProjectController = {
|
||||||
'ieee-stylesheet',
|
'ieee-stylesheet',
|
||||||
'write-and-cite',
|
'write-and-cite',
|
||||||
'default-visual-for-beginners',
|
'default-visual-for-beginners',
|
||||||
|
'spell-check-client',
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
const getUserValues = async userId =>
|
const getUserValues = async userId =>
|
||||||
|
|
|
@ -7,7 +7,7 @@ const LearnedWordsManager = require('./LearnedWordsManager')
|
||||||
const TEN_SECONDS = 1000 * 10
|
const TEN_SECONDS = 1000 * 10
|
||||||
|
|
||||||
const languageCodeIsSupported = code =>
|
const languageCodeIsSupported = code =>
|
||||||
Settings.languages.some(lang => lang.code === code)
|
Settings.languages.some(lang => lang.code === code && lang.server !== false)
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
learn(req, res, next) {
|
learn(req, res, next) {
|
||||||
|
|
|
@ -174,6 +174,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
|
||||||
}
|
}
|
||||||
|
|
||||||
res.locals.mathJaxPath = `/js/libs/mathjax-${PackageVersions.version.mathjax}/es5/tex-svg-full.js`
|
res.locals.mathJaxPath = `/js/libs/mathjax-${PackageVersions.version.mathjax}/es5/tex-svg-full.js`
|
||||||
|
res.locals.dictionariesRoot = `/js/dictionaries/${PackageVersions.version.dictionaries}/`
|
||||||
|
|
||||||
res.locals.lib = PackageVersions.lib
|
res.locals.lib = PackageVersions.lib
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const version = {
|
const version = {
|
||||||
mathjax: '3.2.2',
|
mathjax: '3.2.2',
|
||||||
|
dictionaries: '0.0.2',
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
|
@ -47,6 +47,7 @@ html(
|
||||||
//- See: https://webpack.js.org/guides/public-path/#on-the-fly
|
//- See: https://webpack.js.org/guides/public-path/#on-the-fly
|
||||||
meta(name="ol-baseAssetPath" content=buildBaseAssetPath())
|
meta(name="ol-baseAssetPath" content=buildBaseAssetPath())
|
||||||
meta(name="ol-mathJaxPath" content=mathJaxPath)
|
meta(name="ol-mathJaxPath" content=mathJaxPath)
|
||||||
|
meta(name="ol-dictionariesRoot" content=dictionariesRoot)
|
||||||
|
|
||||||
meta(name="ol-usersEmail" content=getUserEmail())
|
meta(name="ol-usersEmail" content=getUserEmail())
|
||||||
meta(name="ol-ab" data-type="json" content={})
|
meta(name="ol-ab" data-type="json" content={})
|
||||||
|
|
|
@ -8,10 +8,9 @@ if [[ "$BRANCH_NAME" == "master" || "$BRANCH_NAME" == "main" ]]; then
|
||||||
cd sentry_upload/public
|
cd sentry_upload/public
|
||||||
|
|
||||||
SENTRY_RELEASE=${COMMIT_SHA}
|
SENTRY_RELEASE=${COMMIT_SHA}
|
||||||
OPTS="--no-rewrite --url-prefix ~"
|
|
||||||
sentry-cli releases new "$SENTRY_RELEASE"
|
sentry-cli releases new "$SENTRY_RELEASE"
|
||||||
sentry-cli releases set-commits --auto "$SENTRY_RELEASE"
|
sentry-cli releases set-commits --auto "$SENTRY_RELEASE"
|
||||||
sentry-cli releases files "$SENTRY_RELEASE" upload-sourcemaps ${OPTS} .
|
sentry-cli releases files "$SENTRY_RELEASE" upload-sourcemaps --ignore '*/dictionaries/*' --no-rewrite --url-prefix ~ .
|
||||||
sentry-cli releases finalize "$SENTRY_RELEASE"
|
sentry-cli releases finalize "$SENTRY_RELEASE"
|
||||||
|
|
||||||
rm -rf sentry_upload
|
rm -rf sentry_upload
|
||||||
|
|
|
@ -428,58 +428,115 @@ module.exports = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Spelling languages
|
// Spelling languages
|
||||||
|
// dic = available in client
|
||||||
|
// server: false = not available on server
|
||||||
// ------------------
|
// ------------------
|
||||||
//
|
|
||||||
// You must have the corresponding aspell package installed to
|
|
||||||
// be able to use a language.
|
|
||||||
languages: [
|
languages: [
|
||||||
{ code: 'en', name: 'English' },
|
{ code: 'en', name: 'English' },
|
||||||
{ code: 'en_US', name: 'English (American)' },
|
{ code: 'en_US', dic: 'en_US', name: 'English (American)' },
|
||||||
{ code: 'en_GB', name: 'English (British)' },
|
{ code: 'en_GB', dic: 'en_GB', name: 'English (British)' },
|
||||||
{ code: 'en_CA', name: 'English (Canadian)' },
|
{ code: 'en_CA', dic: 'en_CA', name: 'English (Canadian)' },
|
||||||
{ code: 'af', name: 'Afrikaans' },
|
{
|
||||||
{ code: 'ar', name: 'Arabic' },
|
code: 'en_AU',
|
||||||
{ code: 'gl', name: 'Galician' },
|
dic: 'en_AU',
|
||||||
{ code: 'eu', name: 'Basque' },
|
name: 'English (Australian)',
|
||||||
{ code: 'br', name: 'Breton' },
|
server: false,
|
||||||
{ code: 'bg', name: 'Bulgarian' },
|
},
|
||||||
{ code: 'ca', name: 'Catalan' },
|
{
|
||||||
{ code: 'hr', name: 'Croatian' },
|
code: 'en_ZA',
|
||||||
{ code: 'cs', name: 'Czech' },
|
dic: 'en_ZA',
|
||||||
{ code: 'da', name: 'Danish' },
|
name: 'English (South African)',
|
||||||
{ code: 'nl', name: 'Dutch' },
|
server: false,
|
||||||
{ code: 'eo', name: 'Esperanto' },
|
},
|
||||||
{ code: 'et', name: 'Estonian' },
|
{ code: 'af', dic: 'af_ZA', name: 'Afrikaans' },
|
||||||
{ code: 'fo', name: 'Faroese' },
|
{ code: 'an', dic: 'an_ES', name: 'Aragonese', server: false },
|
||||||
{ code: 'fr', name: 'French' },
|
{ code: 'ar', dic: 'ar', name: 'Arabic' },
|
||||||
{ code: 'de', name: 'German' },
|
{ code: 'be_BY', dic: 'be_BY', name: 'Belarusian', server: false },
|
||||||
{ code: 'el', name: 'Greek' },
|
{ code: 'gl', dic: 'gl_ES', name: 'Galician' },
|
||||||
{ code: 'id', name: 'Indonesian' },
|
{ code: 'eu', dic: 'eu', name: 'Basque' },
|
||||||
{ code: 'ga', name: 'Irish' },
|
{ code: 'bn_BD', dic: 'bn_BD', name: 'Bengali', server: false },
|
||||||
{ code: 'it', name: 'Italian' },
|
{ code: 'bs_BA', dic: 'bs_BA', name: 'Bosnian', server: false },
|
||||||
{ code: 'kk', name: 'Kazakh' },
|
{ code: 'br', dic: 'br_FR', name: 'Breton' },
|
||||||
|
{ code: 'bg', dic: 'bg_BG', name: 'Bulgarian' },
|
||||||
|
{ code: 'ca', dic: 'ca', name: 'Catalan' },
|
||||||
|
{ code: 'hr', dic: 'hr_HR', name: 'Croatian' },
|
||||||
|
{ code: 'cs', dic: 'cs_CZ', name: 'Czech' },
|
||||||
|
{
|
||||||
|
code: 'da',
|
||||||
|
// dic: 'da_DK', TODO: re-enable client spell check
|
||||||
|
name: 'Danish',
|
||||||
|
},
|
||||||
|
{ code: 'nl', dic: 'nl', name: 'Dutch' },
|
||||||
|
{ code: 'dz', dic: 'dz', name: 'Dzongkha', server: false },
|
||||||
|
{ code: 'eo', dic: 'eo', name: 'Esperanto' },
|
||||||
|
{ code: 'et', dic: 'et_EE', name: 'Estonian' },
|
||||||
|
{ code: 'fo', dic: 'fo', name: 'Faroese' },
|
||||||
|
{ code: 'fr', dic: 'fr', name: 'French' },
|
||||||
|
{ code: 'gl_ES', dic: 'gl_ES', name: 'Galician', server: false },
|
||||||
|
{ code: 'de', dic: 'de_DE', name: 'German' },
|
||||||
|
{ code: 'de_AT', dic: 'de_AT', name: 'German (Austria)', server: false },
|
||||||
|
{
|
||||||
|
code: 'de_CH',
|
||||||
|
dic: 'de_CH',
|
||||||
|
name: 'German (Switzerland)',
|
||||||
|
server: false,
|
||||||
|
},
|
||||||
|
{ code: 'el', dic: 'el_GR', name: 'Greek' },
|
||||||
|
{ code: 'gug_PY', dic: 'gug_PY', name: 'Guarani', server: false },
|
||||||
|
{ code: 'gu_IN', dic: 'gu_IN', name: 'Gujarati', server: false },
|
||||||
|
{ code: 'he_IL', dic: 'he_IL', name: 'Hebrew', server: false },
|
||||||
|
{ code: 'hi_IN', dic: 'hi_IN', name: 'Hindi', server: false },
|
||||||
|
{ code: 'hu_HU', dic: 'hu_HU', name: 'Hungarian', server: false },
|
||||||
|
{ code: 'is_IS', dic: 'is_IS', name: 'Icelandic', server: false },
|
||||||
|
{ code: 'id', dic: 'id_ID', name: 'Indonesian' },
|
||||||
|
{ code: 'ga', dic: 'ga_IE', name: 'Irish' },
|
||||||
|
{ code: 'it', dic: 'it_IT', name: 'Italian' },
|
||||||
|
{ code: 'kk', dic: 'kk_KZ', name: 'Kazakh' },
|
||||||
|
{ code: 'ko', dic: 'ko', name: 'Korean', server: false },
|
||||||
{ code: 'ku', name: 'Kurdish' },
|
{ code: 'ku', name: 'Kurdish' },
|
||||||
{ code: 'lv', name: 'Latvian' },
|
{ code: 'kmr', dic: 'kmr_Latn', name: 'Kurmanji', server: false },
|
||||||
{ code: 'lt', name: 'Lithuanian' },
|
{ code: 'lv', dic: 'lv_LV', name: 'Latvian' },
|
||||||
|
{ code: 'lt', dic: 'lt_LT', name: 'Lithuanian' },
|
||||||
|
{ code: 'lo_LA', dic: 'lo_LA', name: 'Laotian', server: false },
|
||||||
|
{ code: 'ml_IN', dic: 'ml_IN', name: 'Malayalam', server: false },
|
||||||
|
{ code: 'mn_MN', dic: 'mn_MN', name: 'Mongolian', server: false },
|
||||||
{ code: 'nr', name: 'Ndebele' },
|
{ code: 'nr', name: 'Ndebele' },
|
||||||
|
{ code: 'ne_NP', dic: 'ne_NP', name: 'Nepali', server: false },
|
||||||
{ code: 'ns', name: 'Northern Sotho' },
|
{ code: 'ns', name: 'Northern Sotho' },
|
||||||
{ code: 'no', name: 'Norwegian' },
|
{ code: 'no', dic: 'nn_NO', name: 'Norwegian' },
|
||||||
{ code: 'fa', name: 'Persian' },
|
{ code: 'oc_FR', dic: 'oc_FR', name: 'Occitan', server: false },
|
||||||
{ code: 'pl', name: 'Polish' },
|
{ code: 'fa', dic: 'fa_IR', name: 'Persian' },
|
||||||
{ code: 'pt_BR', name: 'Portuguese (Brazilian)' },
|
{ code: 'pl', dic: 'pl_PL', name: 'Polish' },
|
||||||
{ code: 'pt_PT', name: 'Portuguese (European)' },
|
{ code: 'pt_BR', dic: 'pt_BR', name: 'Portuguese (Brazilian)' },
|
||||||
|
{
|
||||||
|
code: 'pt_PT',
|
||||||
|
dic: 'pt_PT',
|
||||||
|
name: 'Portuguese (European)',
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
{ code: 'pa', name: 'Punjabi' },
|
{ code: 'pa', name: 'Punjabi' },
|
||||||
{ code: 'ro', name: 'Romanian' },
|
{ code: 'ro', dic: 'ro_RO', name: 'Romanian' },
|
||||||
{ code: 'ru', name: 'Russian' },
|
{ code: 'ru', dic: 'ru_RU', name: 'Russian' },
|
||||||
{ code: 'sk', name: 'Slovak' },
|
{ code: 'gd_GB', dic: 'gd_GB', name: 'Scottish Gaelic', server: false },
|
||||||
{ code: 'sl', name: 'Slovenian' },
|
{ code: 'sr_RS', dic: 'sr_RS', name: 'Serbian', server: false },
|
||||||
|
{ code: 'si_LK', dic: 'si_LK', name: 'Sinhala', server: false },
|
||||||
|
{ code: 'sk', dic: 'sk_SK', name: 'Slovak' },
|
||||||
|
{ code: 'sl', dic: 'sl_SI', name: 'Slovenian' },
|
||||||
{ code: 'st', name: 'Southern Sotho' },
|
{ code: 'st', name: 'Southern Sotho' },
|
||||||
{ code: 'es', name: 'Spanish' },
|
{ code: 'es', dic: 'es_ES', name: 'Spanish' },
|
||||||
{ code: 'sv', name: 'Swedish' },
|
{ code: 'sw_TZ', dic: 'sw_TZ', name: 'Swahili', server: false },
|
||||||
{ code: 'tl', name: 'Tagalog' },
|
{ code: 'sv', dic: 'sv_SE', name: 'Swedish' },
|
||||||
|
{ code: 'tl', dic: 'tl', name: 'Tagalog' },
|
||||||
|
{ code: 'te_IN', dic: 'te_IN', name: 'Telugu', server: false },
|
||||||
|
{ code: 'th_TH', dic: 'th_TH', name: 'Thai', server: false },
|
||||||
|
{ code: 'bo', dic: 'bo', name: 'Tibetan', server: false },
|
||||||
{ code: 'ts', name: 'Tsonga' },
|
{ code: 'ts', name: 'Tsonga' },
|
||||||
{ code: 'tn', name: 'Tswana' },
|
{ code: 'tn', name: 'Tswana' },
|
||||||
|
{ code: 'tr_TR', dic: 'tr_TR', name: 'Turkish', server: false },
|
||||||
|
{ code: 'uk_UA', dic: 'uk_UA', name: 'Ukrainian', server: false },
|
||||||
{ code: 'hsb', name: 'Upper Sorbian' },
|
{ code: 'hsb', name: 'Upper Sorbian' },
|
||||||
|
{ code: 'uz_UZ', dic: 'uz_UZ', name: 'Uzbek', server: false },
|
||||||
|
{ code: 'vi_VN', dic: 'vi_VN', name: 'Vietnamese', server: false },
|
||||||
{ code: 'cy', name: 'Welsh' },
|
{ code: 'cy', name: 'Welsh' },
|
||||||
{ code: 'xh', name: 'Xhosa' },
|
{ code: 'xh', name: 'Xhosa' },
|
||||||
],
|
],
|
||||||
|
|
|
@ -34,6 +34,12 @@ const buildConfig = () => {
|
||||||
'../../frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker'
|
'../../frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// add entrypoint under '/' for hunspell worker
|
||||||
|
addWorker(
|
||||||
|
'hunspell-worker',
|
||||||
|
'../../frontend/js/features/source-editor/hunspell/hunspell.worker'
|
||||||
|
)
|
||||||
|
|
||||||
// add entrypoints under '/' for pdfjs workers
|
// add entrypoints under '/' for pdfjs workers
|
||||||
addWorker('pdfjs-dist213', 'pdfjs-dist213/legacy/build/pdf.worker.js')
|
addWorker('pdfjs-dist213', 'pdfjs-dist213/legacy/build/pdf.worker.js')
|
||||||
addWorker('pdfjs-dist401', 'pdfjs-dist401/legacy/build/pdf.worker.mjs')
|
addWorker('pdfjs-dist401', 'pdfjs-dist401/legacy/build/pdf.worker.mjs')
|
||||||
|
|
|
@ -65,6 +65,7 @@
|
||||||
"add_or_remove_project_from_tag": "",
|
"add_or_remove_project_from_tag": "",
|
||||||
"add_people": "",
|
"add_people": "",
|
||||||
"add_role_and_department": "",
|
"add_role_and_department": "",
|
||||||
|
"add_to_dictionary": "",
|
||||||
"add_to_tag": "",
|
"add_to_tag": "",
|
||||||
"add_your_comment_here": "",
|
"add_your_comment_here": "",
|
||||||
"add_your_first_group_member_now": "",
|
"add_your_first_group_member_now": "",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import getMeta from '../../utils/meta'
|
import getMeta from '../../utils/meta'
|
||||||
|
|
||||||
const IGNORED_MISSPELLINGS = [
|
export const globalLearnedWords = new Set([
|
||||||
'Overleaf',
|
'Overleaf',
|
||||||
'overleaf',
|
'overleaf',
|
||||||
'ShareLaTeX',
|
'ShareLaTeX',
|
||||||
|
@ -21,15 +21,15 @@ const IGNORED_MISSPELLINGS = [
|
||||||
'lockdown',
|
'lockdown',
|
||||||
'Coronavirus',
|
'Coronavirus',
|
||||||
'coronavirus',
|
'coronavirus',
|
||||||
]
|
])
|
||||||
|
|
||||||
export class IgnoredWords {
|
export class IgnoredWords {
|
||||||
public learnedWords!: Set<string>
|
public learnedWords!: Set<string>
|
||||||
private ignoredMisspellings: Set<string>
|
private readonly ignoredMisspellings: Set<string>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.reset()
|
this.reset()
|
||||||
this.ignoredMisspellings = new Set(IGNORED_MISSPELLINGS)
|
this.ignoredMisspellings = globalLearnedWords
|
||||||
window.addEventListener('learnedWords:doreset', () => this.reset()) // for tests
|
window.addEventListener('learnedWords:doreset', () => this.reset()) // for tests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,25 +4,39 @@ import getMeta from '../../../../utils/meta'
|
||||||
import { useProjectSettingsContext } from '../../context/project-settings-context'
|
import { useProjectSettingsContext } from '../../context/project-settings-context'
|
||||||
import SettingsMenuSelect from './settings-menu-select'
|
import SettingsMenuSelect from './settings-menu-select'
|
||||||
import type { Optgroup } from './settings-menu-select'
|
import type { Optgroup } from './settings-menu-select'
|
||||||
|
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||||
|
|
||||||
|
// TODO: set to true when ready to show new languages that are only available in the client
|
||||||
|
const showClientOnlyLanguages = false
|
||||||
|
|
||||||
export default function SettingsSpellCheckLanguage() {
|
export default function SettingsSpellCheckLanguage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const languages = getMeta('ol-languages')
|
const languages = getMeta('ol-languages')
|
||||||
|
|
||||||
|
const spellCheckClientEnabled = useFeatureFlag('spell-check-client')
|
||||||
|
|
||||||
const { spellCheckLanguage, setSpellCheckLanguage } =
|
const { spellCheckLanguage, setSpellCheckLanguage } =
|
||||||
useProjectSettingsContext()
|
useProjectSettingsContext()
|
||||||
|
|
||||||
const optgroup: Optgroup = useMemo(
|
const optgroup: Optgroup = useMemo(() => {
|
||||||
() => ({
|
const options = (languages ?? []).filter(lang => {
|
||||||
|
const clientOnly = lang.server === false
|
||||||
|
|
||||||
|
if (clientOnly && !showClientOnlyLanguages) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return spellCheckClientEnabled || !clientOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
label: 'Language',
|
label: 'Language',
|
||||||
options:
|
options: options.map(language => ({
|
||||||
languages?.map(language => ({
|
value: language.code,
|
||||||
value: language.code,
|
label: language.name,
|
||||||
label: language.name,
|
})),
|
||||||
})) ?? [],
|
}
|
||||||
}),
|
}, [languages, spellCheckClientEnabled])
|
||||||
[languages]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsMenuSelect
|
<SettingsMenuSelect
|
||||||
|
|
|
@ -1,327 +0,0 @@
|
||||||
import {
|
|
||||||
StateField,
|
|
||||||
StateEffect,
|
|
||||||
EditorSelection,
|
|
||||||
Prec,
|
|
||||||
} from '@codemirror/state'
|
|
||||||
import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
|
|
||||||
import { addIgnoredWord } from './ignored-words'
|
|
||||||
import { learnWordRequest } from './backend'
|
|
||||||
import { Word, Mark, getMarkAtPosition } from './spellchecker'
|
|
||||||
import { debugConsole } from '@/utils/debugging'
|
|
||||||
|
|
||||||
const ITEMS_TO_SHOW = 8
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
|
|
||||||
* Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
|
|
||||||
*/
|
|
||||||
let openingUntil = 0
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Hide the spelling menu on click
|
|
||||||
*/
|
|
||||||
const handleClickEvent = (event: MouseEvent, view: EditorView) => {
|
|
||||||
if (Date.now() < openingUntil) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (view.state.field(spellingMenuField, false)) {
|
|
||||||
view.dispatch({
|
|
||||||
effects: hideSpellingMenu.of(null),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Detect when the user right-clicks on a misspelled word,
|
|
||||||
* and show a menu of suggestions
|
|
||||||
*/
|
|
||||||
const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
|
|
||||||
const position = view.posAtCoords(
|
|
||||||
{
|
|
||||||
x: event.pageX,
|
|
||||||
y: event.pageY,
|
|
||||||
},
|
|
||||||
false
|
|
||||||
)
|
|
||||||
const targetMark = getMarkAtPosition(view, position)
|
|
||||||
|
|
||||||
if (!targetMark) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { from, to, value } = targetMark
|
|
||||||
|
|
||||||
const targetWord = value.spec.word
|
|
||||||
if (!targetWord) {
|
|
||||||
debugConsole.debug(
|
|
||||||
'>> spelling no word associated with decorated range, stopping'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
openingUntil = Date.now() + 100
|
|
||||||
|
|
||||||
view.dispatch({
|
|
||||||
selection: EditorSelection.range(from, to),
|
|
||||||
effects: showSpellingMenu.of({
|
|
||||||
mark: targetMark,
|
|
||||||
word: targetWord,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShortcutEvent = (view: EditorView) => {
|
|
||||||
const targetMark = getMarkAtPosition(view, view.state.selection.main.from)
|
|
||||||
|
|
||||||
if (!targetMark || !targetMark.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
view.dispatch({
|
|
||||||
selection: EditorSelection.range(targetMark.from, targetMark.to),
|
|
||||||
effects: showSpellingMenu.of({
|
|
||||||
mark: targetMark,
|
|
||||||
word: targetMark.value.spec.word,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Spelling menu "tooltip" field.
|
|
||||||
* Manages the menu of suggestions shown on right-click
|
|
||||||
*/
|
|
||||||
export const spellingMenuField = StateField.define<Tooltip | null>({
|
|
||||||
create() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
update(value, transaction) {
|
|
||||||
if (value) {
|
|
||||||
value = {
|
|
||||||
...value,
|
|
||||||
pos: transaction.changes.mapPos(value.pos),
|
|
||||||
end: value.end ? transaction.changes.mapPos(value.end) : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const effect of transaction.effects) {
|
|
||||||
if (effect.is(hideSpellingMenu)) {
|
|
||||||
value = null
|
|
||||||
} else if (effect.is(showSpellingMenu)) {
|
|
||||||
const { mark, word } = effect.value
|
|
||||||
// Build a "Tooltip" showing the suggestions
|
|
||||||
value = {
|
|
||||||
pos: mark.from,
|
|
||||||
end: mark.to,
|
|
||||||
above: false,
|
|
||||||
strictSide: false,
|
|
||||||
create: view => {
|
|
||||||
return createSpellingSuggestionList(word, view)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
provide: field => {
|
|
||||||
return [
|
|
||||||
showTooltip.from(field),
|
|
||||||
EditorView.domEventHandlers({
|
|
||||||
contextmenu: handleContextMenuEvent,
|
|
||||||
click: handleClickEvent,
|
|
||||||
}),
|
|
||||||
Prec.highest(
|
|
||||||
keymap.of([
|
|
||||||
{ key: 'Ctrl-Space', run: handleShortcutEvent },
|
|
||||||
{ key: 'Alt-Space', run: handleShortcutEvent },
|
|
||||||
])
|
|
||||||
),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
|
|
||||||
|
|
||||||
const hideSpellingMenu = StateEffect.define()
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Creates the suggestion menu dom, to be displayed in the
|
|
||||||
* spelling menu "tooltip"
|
|
||||||
* */
|
|
||||||
const createSpellingSuggestionList = (word: Word, view: EditorView) => {
|
|
||||||
// Wrapper div.
|
|
||||||
// Note, CM6 doesn't like showing complex elements
|
|
||||||
// 'inside' its Tooltip element, so we style this
|
|
||||||
// wrapper div to be basically invisible, and allow
|
|
||||||
// the dropdown list to hang off of it, giving the illusion that
|
|
||||||
// the list _is_ the tooltip.
|
|
||||||
// See the theme in spelling/index for styling that makes this work.
|
|
||||||
const dom = document.createElement('div')
|
|
||||||
dom.classList.add('ol-cm-spelling-context-menu-tooltip')
|
|
||||||
|
|
||||||
// List
|
|
||||||
const list = document.createElement('ul')
|
|
||||||
list.setAttribute('tabindex', '0')
|
|
||||||
list.setAttribute('role', 'menu')
|
|
||||||
list.addEventListener('keydown', event => {
|
|
||||||
if (event.code === 'Tab') {
|
|
||||||
// preventing selecting next element
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
list.addEventListener('keyup', event => {
|
|
||||||
switch (event.code) {
|
|
||||||
case 'ArrowDown': {
|
|
||||||
// get currently selected option
|
|
||||||
const selectedButton =
|
|
||||||
list.querySelector<HTMLButtonElement>('li button:focus')
|
|
||||||
|
|
||||||
if (!selectedButton) {
|
|
||||||
return list
|
|
||||||
.querySelector<HTMLButtonElement>('li[role="option"] button')
|
|
||||||
?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// get next option
|
|
||||||
let nextElement = selectedButton.parentElement?.nextElementSibling
|
|
||||||
if (nextElement?.getAttribute('role') !== 'option') {
|
|
||||||
nextElement = nextElement?.nextElementSibling
|
|
||||||
}
|
|
||||||
nextElement?.querySelector('button')?.focus()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'ArrowUp': {
|
|
||||||
// get currently selected option
|
|
||||||
const selectedButton =
|
|
||||||
list.querySelector<HTMLButtonElement>('li button:focus')
|
|
||||||
|
|
||||||
if (!selectedButton) {
|
|
||||||
return list
|
|
||||||
.querySelector<HTMLButtonElement>(
|
|
||||||
'li[role="option"]:last-child button'
|
|
||||||
)
|
|
||||||
?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// get previous option
|
|
||||||
let previousElement =
|
|
||||||
selectedButton.parentElement?.previousElementSibling
|
|
||||||
if (previousElement?.getAttribute('role') !== 'option') {
|
|
||||||
previousElement = previousElement?.previousElementSibling
|
|
||||||
}
|
|
||||||
previousElement?.querySelector('button')?.focus()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'Escape':
|
|
||||||
case 'Tab': {
|
|
||||||
view.dispatch({
|
|
||||||
effects: hideSpellingMenu.of(null),
|
|
||||||
})
|
|
||||||
view.focus()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned')
|
|
||||||
|
|
||||||
// List items, with links inside
|
|
||||||
if (Array.isArray(word.suggestions)) {
|
|
||||||
for (const suggestion of word.suggestions.slice(0, ITEMS_TO_SHOW)) {
|
|
||||||
const li = makeLinkItem(suggestion, event => {
|
|
||||||
const text = (event.target as HTMLElement).innerText
|
|
||||||
handleCorrectWord(word, text, view)
|
|
||||||
event.preventDefault()
|
|
||||||
})
|
|
||||||
list.appendChild(li)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
list.querySelector<HTMLButtonElement>('li:first-child button')?.focus()
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
// Divider
|
|
||||||
const divider = document.createElement('li')
|
|
||||||
divider.classList.add('divider')
|
|
||||||
list.append(divider)
|
|
||||||
|
|
||||||
// Add to Dictionary
|
|
||||||
const addToDictionary = makeLinkItem(
|
|
||||||
'Add to Dictionary',
|
|
||||||
async function (event: Event) {
|
|
||||||
await handleLearnWord(word, view)
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
list.append(addToDictionary)
|
|
||||||
|
|
||||||
dom.appendChild(list)
|
|
||||||
return { dom }
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeLinkItem = (suggestion: string, handler: EventListener) => {
|
|
||||||
const li = document.createElement('li')
|
|
||||||
const button = document.createElement('button')
|
|
||||||
li.setAttribute('role', 'option')
|
|
||||||
button.classList.add('btn-link', 'text-left', 'dropdown-menu-button')
|
|
||||||
button.onclick = handler
|
|
||||||
button.textContent = suggestion
|
|
||||||
li.appendChild(button)
|
|
||||||
return li
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Learn a word, adding it to the local cache
|
|
||||||
* and sending it to the spelling backend
|
|
||||||
*/
|
|
||||||
const handleLearnWord = async function (word: Word, view: EditorView) {
|
|
||||||
try {
|
|
||||||
await learnWordRequest(word)
|
|
||||||
view.dispatch({
|
|
||||||
effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
debugConsole.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Correct a word, removing the marked range
|
|
||||||
* and replacing it with the chosen text
|
|
||||||
*/
|
|
||||||
const handleCorrectWord = (word: Word, text: string, view: EditorView) => {
|
|
||||||
const tooltip = view.state.field(spellingMenuField)
|
|
||||||
if (!tooltip) {
|
|
||||||
throw new Error('No active tooltip')
|
|
||||||
}
|
|
||||||
const existingText = view.state.doc.sliceString(tooltip.pos, tooltip.end)
|
|
||||||
// Defend against erroneous replacement, if the word at this
|
|
||||||
// position is not actually what we think it is
|
|
||||||
if (existingText !== word.text) {
|
|
||||||
debugConsole.debug(
|
|
||||||
'>> spelling word-to-correct does not match, stopping',
|
|
||||||
tooltip.pos,
|
|
||||||
tooltip.end,
|
|
||||||
existingText,
|
|
||||||
word
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
view.dispatch({
|
|
||||||
changes: [
|
|
||||||
{
|
|
||||||
from: tooltip.pos,
|
|
||||||
to: tooltip.end,
|
|
||||||
insert: text,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
effects: [hideSpellingMenu.of(null)],
|
|
||||||
})
|
|
||||||
view.focus()
|
|
||||||
}
|
|
|
@ -0,0 +1,224 @@
|
||||||
|
import {
|
||||||
|
StateField,
|
||||||
|
StateEffect,
|
||||||
|
EditorSelection,
|
||||||
|
Prec,
|
||||||
|
} from '@codemirror/state'
|
||||||
|
import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
|
||||||
|
import { addIgnoredWord } from './ignored-words'
|
||||||
|
import { learnWordRequest } from './backend'
|
||||||
|
import { Word, Mark, getMarkAtPosition } from './spellchecker'
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
import {
|
||||||
|
getSpellChecker,
|
||||||
|
getSpellCheckLanguage,
|
||||||
|
} from '@/features/source-editor/extensions/spelling/index'
|
||||||
|
import { sendMB } from '@/infrastructure/event-tracking'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { SpellingSuggestions } from '@/features/source-editor/extensions/spelling/spelling-suggestions'
|
||||||
|
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
|
||||||
|
* Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
|
||||||
|
*/
|
||||||
|
let openingUntil = 0
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hide the spelling menu on click
|
||||||
|
*/
|
||||||
|
const handleClickEvent = (event: MouseEvent, view: EditorView) => {
|
||||||
|
if (Date.now() < openingUntil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.state.field(spellingMenuField, false)) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: hideSpellingMenu.of(null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Detect when the user right-clicks on a misspelled word,
|
||||||
|
* and show a menu of suggestions
|
||||||
|
*/
|
||||||
|
const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
|
||||||
|
const position = view.posAtCoords(
|
||||||
|
{
|
||||||
|
x: event.pageX,
|
||||||
|
y: event.pageY,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
)
|
||||||
|
const targetMark = getMarkAtPosition(view, position)
|
||||||
|
|
||||||
|
if (!targetMark) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to, value } = targetMark
|
||||||
|
|
||||||
|
const targetWord = value.spec.word
|
||||||
|
if (!targetWord) {
|
||||||
|
debugConsole.debug(
|
||||||
|
'>> spelling no word associated with decorated range, stopping'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
openingUntil = Date.now() + 100
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.range(from, to),
|
||||||
|
effects: showSpellingMenu.of({
|
||||||
|
mark: targetMark,
|
||||||
|
word: targetWord,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShortcutEvent = (view: EditorView) => {
|
||||||
|
const targetMark = getMarkAtPosition(view, view.state.selection.main.from)
|
||||||
|
|
||||||
|
if (!targetMark || !targetMark.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.range(targetMark.from, targetMark.to),
|
||||||
|
effects: showSpellingMenu.of({
|
||||||
|
mark: targetMark,
|
||||||
|
word: targetMark.value.spec.word,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Spelling menu "tooltip" field.
|
||||||
|
* Manages the menu of suggestions shown on right-click
|
||||||
|
*/
|
||||||
|
export const spellingMenuField = StateField.define<Tooltip | null>({
|
||||||
|
create() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
update(value, transaction) {
|
||||||
|
if (value) {
|
||||||
|
value = {
|
||||||
|
...value,
|
||||||
|
pos: transaction.changes.mapPos(value.pos),
|
||||||
|
end: value.end ? transaction.changes.mapPos(value.end) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const effect of transaction.effects) {
|
||||||
|
if (effect.is(hideSpellingMenu)) {
|
||||||
|
value = null
|
||||||
|
} else if (effect.is(showSpellingMenu)) {
|
||||||
|
const { mark, word } = effect.value
|
||||||
|
// Build a "Tooltip" showing the suggestions
|
||||||
|
value = {
|
||||||
|
pos: mark.from,
|
||||||
|
end: mark.to,
|
||||||
|
above: false,
|
||||||
|
strictSide: false,
|
||||||
|
create: createSpellingSuggestionList(word),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
provide: field => {
|
||||||
|
return [
|
||||||
|
showTooltip.from(field),
|
||||||
|
EditorView.domEventHandlers({
|
||||||
|
contextmenu: handleContextMenuEvent,
|
||||||
|
click: handleClickEvent,
|
||||||
|
}),
|
||||||
|
Prec.highest(
|
||||||
|
keymap.of([
|
||||||
|
{ key: 'Ctrl-Space', run: handleShortcutEvent },
|
||||||
|
{ key: 'Alt-Space', run: handleShortcutEvent },
|
||||||
|
])
|
||||||
|
),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
|
||||||
|
|
||||||
|
export const hideSpellingMenu = StateEffect.define()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Creates the suggestion menu dom, to be displayed in the
|
||||||
|
* spelling menu "tooltip"
|
||||||
|
* */
|
||||||
|
const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
|
||||||
|
const dom = document.createElement('div')
|
||||||
|
dom.classList.add('ol-cm-spelling-context-menu-tooltip')
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<SplitTestProvider>
|
||||||
|
<SpellingSuggestions
|
||||||
|
word={word}
|
||||||
|
spellCheckLanguage={getSpellCheckLanguage(view.state)}
|
||||||
|
spellChecker={getSpellChecker(view.state)}
|
||||||
|
handleClose={() => {
|
||||||
|
view.dispatch({
|
||||||
|
effects: hideSpellingMenu.of(null),
|
||||||
|
})
|
||||||
|
view.focus()
|
||||||
|
}}
|
||||||
|
handleLearnWord={() => {
|
||||||
|
learnWordRequest(word)
|
||||||
|
.then(() => {
|
||||||
|
view.dispatch({
|
||||||
|
effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
|
||||||
|
})
|
||||||
|
sendMB('spelling-word-added', {
|
||||||
|
language: getSpellCheckLanguage(view.state),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
debugConsole.error(error)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
handleCorrectWord={(text: string) => {
|
||||||
|
const tooltip = view.state.field(spellingMenuField)
|
||||||
|
if (!tooltip) {
|
||||||
|
throw new Error('No active tooltip')
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingText = view.state.doc.sliceString(
|
||||||
|
tooltip.pos,
|
||||||
|
tooltip.end
|
||||||
|
)
|
||||||
|
if (existingText !== word.text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }],
|
||||||
|
effects: [hideSpellingMenu.of(null)],
|
||||||
|
})
|
||||||
|
view.focus()
|
||||||
|
|
||||||
|
sendMB('spelling-suggestion-click', {
|
||||||
|
language: getSpellCheckLanguage(view.state),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SplitTestProvider>,
|
||||||
|
dom
|
||||||
|
)
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
ReactDOM.unmountComponentAtNode(dom)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dom, destroy }
|
||||||
|
}
|
|
@ -20,6 +20,12 @@ export const addIgnoredWord = StateEffect.define<{
|
||||||
text: string
|
text: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
export const removeIgnoredWord = StateEffect.define<{
|
||||||
|
text: string
|
||||||
|
}>()
|
||||||
|
|
||||||
export const updateAfterAddingIgnoredWord = StateEffect.define<string>()
|
export const updateAfterAddingIgnoredWord = StateEffect.define<string>()
|
||||||
|
|
||||||
|
export const updateAfterRemovingIgnoredWord = StateEffect.define<string>()
|
||||||
|
|
||||||
export const resetSpellChecker = StateEffect.define()
|
export const resetSpellChecker = StateEffect.define()
|
||||||
|
|
|
@ -1,26 +1,29 @@
|
||||||
import { EditorView, ViewPlugin } from '@codemirror/view'
|
import { EditorView, ViewPlugin } from '@codemirror/view'
|
||||||
import {
|
import {
|
||||||
Compartment,
|
EditorState,
|
||||||
Facet,
|
|
||||||
StateEffect,
|
StateEffect,
|
||||||
StateField,
|
StateField,
|
||||||
TransactionSpec,
|
TransactionSpec,
|
||||||
} from '@codemirror/state'
|
} from '@codemirror/state'
|
||||||
import { misspelledWordsField, resetMisspelledWords } from './misspelled-words'
|
import { misspelledWordsField } from './misspelled-words'
|
||||||
import {
|
import {
|
||||||
|
addIgnoredWord,
|
||||||
ignoredWordsField,
|
ignoredWordsField,
|
||||||
|
removeIgnoredWord,
|
||||||
resetSpellChecker,
|
resetSpellChecker,
|
||||||
updateAfterAddingIgnoredWord,
|
updateAfterAddingIgnoredWord,
|
||||||
} from './ignored-words'
|
} from './ignored-words'
|
||||||
import { addWordToCache, cacheField, removeWordFromCache } from './cache'
|
import { addWordToCache, cacheField, removeWordFromCache } from './cache'
|
||||||
import { spellingMenuField } from './context-menu'
|
import { hideSpellingMenu, spellingMenuField } from './context-menu'
|
||||||
import { SpellChecker } from './spellchecker'
|
import { SpellChecker } from './spellchecker'
|
||||||
import { parserWatcher } from '../wait-for-parser'
|
import { parserWatcher } from '../wait-for-parser'
|
||||||
|
import type { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
|
||||||
const spellCheckLanguageConf = new Compartment()
|
type Options = {
|
||||||
const spellCheckLanguageFacet = Facet.define<string | undefined>()
|
spellCheckLanguage?: string
|
||||||
|
hunspellManager?: HunspellManager
|
||||||
type Options = { spellCheckLanguage?: string }
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom extension that creates a spell checker for the current language (from the user settings).
|
* A custom extension that creates a spell checker for the current language (from the user settings).
|
||||||
|
@ -29,12 +32,16 @@ type Options = { spellCheckLanguage?: string }
|
||||||
* Mis-spelled words are decorated with a Mark decoration.
|
* Mis-spelled words are decorated with a Mark decoration.
|
||||||
* The suggestions menu is displayed in a tooltip, activated with a right-click on the decoration.
|
* The suggestions menu is displayed in a tooltip, activated with a right-click on the decoration.
|
||||||
*/
|
*/
|
||||||
export const spelling = ({ spellCheckLanguage }: Options) => {
|
export const spelling = ({ spellCheckLanguage, hunspellManager }: Options) => {
|
||||||
return [
|
return [
|
||||||
spellingTheme,
|
spellingTheme,
|
||||||
parserWatcher,
|
parserWatcher,
|
||||||
spellCheckLanguageConf.of(spellCheckLanguageFacet.of(spellCheckLanguage)),
|
spellCheckLanguageField.init(() => spellCheckLanguage),
|
||||||
spellCheckField,
|
spellCheckerField.init(() =>
|
||||||
|
spellCheckLanguage
|
||||||
|
? new SpellChecker(spellCheckLanguage, hunspellManager)
|
||||||
|
: null
|
||||||
|
),
|
||||||
misspelledWordsField,
|
misspelledWordsField,
|
||||||
ignoredWordsField,
|
ignoredWordsField,
|
||||||
cacheField,
|
cacheField,
|
||||||
|
@ -56,16 +63,27 @@ const spellingTheme = EditorView.baseTheme({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const spellCheckField = StateField.define<SpellChecker | null>({
|
export const getSpellChecker = (state: EditorState) =>
|
||||||
create(state) {
|
state.field(spellCheckerField, false)
|
||||||
const [spellCheckLanguage] = state.facet(spellCheckLanguageFacet)
|
|
||||||
return spellCheckLanguage ? new SpellChecker(spellCheckLanguage) : null
|
const spellCheckerField = StateField.define<SpellChecker | null>({
|
||||||
|
create() {
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
update(value, tr) {
|
update(value, tr) {
|
||||||
for (const effect of tr.effects) {
|
for (const effect of tr.effects) {
|
||||||
if (effect.is(setSpellCheckLanguageEffect)) {
|
if (effect.is(setSpellCheckLanguageEffect)) {
|
||||||
value?.destroy()
|
value?.destroy()
|
||||||
return effect.value ? new SpellChecker(effect.value) : null
|
value = effect.value.spellCheckLanguage
|
||||||
|
? new SpellChecker(
|
||||||
|
effect.value.spellCheckLanguage,
|
||||||
|
effect.value.hunspellManager
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
} else if (effect.is(addIgnoredWord)) {
|
||||||
|
value?.addWord(effect.value.text).catch(debugConsole.error)
|
||||||
|
} else if (effect.is(removeIgnoredWord)) {
|
||||||
|
value?.removeWord(effect.value.text).catch(debugConsole.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
@ -80,7 +98,7 @@ const spellCheckField = StateField.define<SpellChecker | null>({
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
EditorView.domEventHandlers({
|
EditorView.domEventHandlers({
|
||||||
focus: (event, view) => {
|
focus: (_event, view) => {
|
||||||
if (view.state.facet(EditorView.editable)) {
|
if (view.state.facet(EditorView.editable)) {
|
||||||
view.state.field(field)?.scheduleSpellCheck(view)
|
view.state.field(field)?.scheduleSpellCheck(view)
|
||||||
}
|
}
|
||||||
|
@ -95,18 +113,39 @@ const spellCheckField = StateField.define<SpellChecker | null>({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const setSpellCheckLanguageEffect = StateEffect.define<string | undefined>()
|
export const getSpellCheckLanguage = (state: EditorState) =>
|
||||||
|
state.field(spellCheckLanguageField, false)
|
||||||
|
|
||||||
export const setSpelling = ({
|
const spellCheckLanguageField = StateField.define<string | undefined>({
|
||||||
|
create() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
update(value, tr) {
|
||||||
|
for (const effect of tr.effects) {
|
||||||
|
if (effect.is(setSpellCheckLanguageEffect)) {
|
||||||
|
value = effect.value.spellCheckLanguage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setSpellCheckLanguageEffect = StateEffect.define<{
|
||||||
|
spellCheckLanguage: string | undefined
|
||||||
|
hunspellManager?: HunspellManager
|
||||||
|
}>()
|
||||||
|
|
||||||
|
export const setSpellCheckLanguage = ({
|
||||||
spellCheckLanguage,
|
spellCheckLanguage,
|
||||||
|
hunspellManager,
|
||||||
}: Options): TransactionSpec => {
|
}: Options): TransactionSpec => {
|
||||||
return {
|
return {
|
||||||
effects: [
|
effects: [
|
||||||
resetMisspelledWords.of(null),
|
setSpellCheckLanguageEffect.of({
|
||||||
spellCheckLanguageConf.reconfigure(
|
spellCheckLanguage,
|
||||||
spellCheckLanguageFacet.of(spellCheckLanguage)
|
hunspellManager,
|
||||||
),
|
}),
|
||||||
setSpellCheckLanguageEffect.of(spellCheckLanguage),
|
hideSpellingMenu.of(null),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -137,6 +176,7 @@ export const removeLearnedWord = (
|
||||||
lang: spellCheckLanguage,
|
lang: spellCheckLanguage,
|
||||||
wordText: word,
|
wordText: word,
|
||||||
}),
|
}),
|
||||||
|
removeIgnoredWord.of({ text: word }),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,10 @@ import { StateField, StateEffect } from '@codemirror/state'
|
||||||
import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
|
import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
|
||||||
import { updateAfterAddingIgnoredWord } from './ignored-words'
|
import { updateAfterAddingIgnoredWord } from './ignored-words'
|
||||||
import { Word } from './spellchecker'
|
import { Word } from './spellchecker'
|
||||||
|
import { setSpellCheckLanguageEffect } from '@/features/source-editor/extensions/spelling/index'
|
||||||
|
|
||||||
export const addMisspelledWords = StateEffect.define<Word[]>()
|
export const addMisspelledWords = StateEffect.define<Word[]>()
|
||||||
|
|
||||||
export const resetMisspelledWords = StateEffect.define()
|
|
||||||
|
|
||||||
const createMark = (word: Word) => {
|
const createMark = (word: Word) => {
|
||||||
return Decoration.mark({
|
return Decoration.mark({
|
||||||
class: 'ol-cm-spelling-error',
|
class: 'ol-cm-spelling-error',
|
||||||
|
@ -39,14 +38,17 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
|
||||||
if (effect.is(addMisspelledWords)) {
|
if (effect.is(addMisspelledWords)) {
|
||||||
// Merge the new misspelled words into the existing set of marks
|
// Merge the new misspelled words into the existing set of marks
|
||||||
marks = marks.update({
|
marks = marks.update({
|
||||||
add: effect.value.map(word => createMark(word)),
|
add: effect.value.map(word => createMark(word)), // TODO: make sure these positions are still accurate
|
||||||
sort: true,
|
sort: true,
|
||||||
})
|
})
|
||||||
} else if (effect.is(updateAfterAddingIgnoredWord)) {
|
} else if (effect.is(updateAfterAddingIgnoredWord)) {
|
||||||
// Remove a misspelled word, all instances that match text
|
// Remove existing marks matching the text of a supplied word
|
||||||
const word = effect.value
|
marks = marks.update({
|
||||||
marks = removeAllMarksMatchingWordText(marks, word)
|
filter(_from, _to, mark) {
|
||||||
} else if (effect.is(resetMisspelledWords)) {
|
return mark.spec.word.text !== effect.value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (effect.is(setSpellCheckLanguageEffect)) {
|
||||||
marks = Decoration.none
|
marks = Decoration.none
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -56,14 +58,3 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
|
||||||
return EditorView.decorations.from(field)
|
return EditorView.decorations.from(field)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/*
|
|
||||||
* Remove existing marks matching the text of a supplied word
|
|
||||||
*/
|
|
||||||
const removeAllMarksMatchingWordText = (marks: DecorationSet, word: string) => {
|
|
||||||
return marks.update({
|
|
||||||
filter: (from, to, mark) => {
|
|
||||||
return mark.spec.word.text !== word
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from '../../utils/tree-query'
|
} from '../../utils/tree-query'
|
||||||
import { waitForParser } from '../wait-for-parser'
|
import { waitForParser } from '../wait-for-parser'
|
||||||
import { debugConsole } from '@/utils/debugging'
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
import type { HunspellManager } from '../../hunspell/HunspellManager'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Spellchecker, handles updates, schedules spelling checks
|
* Spellchecker, handles updates, schedules spelling checks
|
||||||
|
@ -26,13 +27,17 @@ export class SpellChecker {
|
||||||
private trackedChanges: ChangeSet
|
private trackedChanges: ChangeSet
|
||||||
|
|
||||||
// eslint-disable-next-line no-useless-constructor
|
// eslint-disable-next-line no-useless-constructor
|
||||||
constructor(private readonly language: string) {
|
constructor(
|
||||||
this.language = language
|
private readonly language: string,
|
||||||
|
private hunspellManager?: HunspellManager
|
||||||
|
) {
|
||||||
|
debugConsole.log('SpellChecker', language, hunspellManager)
|
||||||
this.trackedChanges = ChangeSet.empty(0)
|
this.trackedChanges = ChangeSet.empty(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this._clearPendingSpellCheck()
|
this._clearPendingSpellCheck()
|
||||||
|
// this.hunspellManager?.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
_abortRequest() {
|
_abortRequest() {
|
||||||
|
@ -86,7 +91,7 @@ export class SpellChecker {
|
||||||
wordsToCheck
|
wordsToCheck
|
||||||
)
|
)
|
||||||
const processResult = (
|
const processResult = (
|
||||||
misspellings: { index: number; suggestions: string[] }[]
|
misspellings: { index: number; suggestions?: string[] }[]
|
||||||
) => {
|
) => {
|
||||||
this.trackedChanges = ChangeSet.empty(0)
|
this.trackedChanges = ChangeSet.empty(0)
|
||||||
|
|
||||||
|
@ -108,18 +113,79 @@ export class SpellChecker {
|
||||||
} else {
|
} else {
|
||||||
this._abortRequest()
|
this._abortRequest()
|
||||||
this.abortController = new AbortController()
|
this.abortController = new AbortController()
|
||||||
spellCheckRequest(this.language, unknownWords, this.abortController)
|
if (this.hunspellManager) {
|
||||||
.then(result => {
|
const signal = this.abortController.signal
|
||||||
this.abortController = null
|
this.hunspellManager.send(
|
||||||
processResult(result.misspellings)
|
{
|
||||||
})
|
type: 'spell',
|
||||||
.catch(error => {
|
words: unknownWords.map(word => word.text),
|
||||||
this.abortController = null
|
},
|
||||||
debugConsole.error(error)
|
(result: any) => {
|
||||||
})
|
if (!signal.aborted) {
|
||||||
|
if (result.error) {
|
||||||
|
debugConsole.error(result.error)
|
||||||
|
} else {
|
||||||
|
processResult(result.misspellings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
spellCheckRequest(this.language, unknownWords, this.abortController)
|
||||||
|
.then(result => {
|
||||||
|
this.abortController = null
|
||||||
|
return processResult(result.misspellings)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.abortController = null
|
||||||
|
debugConsole.error(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suggest(word: string) {
|
||||||
|
return new Promise<{ suggestions: string[] }>((resolve, reject) => {
|
||||||
|
if (this.hunspellManager) {
|
||||||
|
this.hunspellManager.send({ type: 'suggest', word }, result => {
|
||||||
|
if ((result as { error: true }).error) {
|
||||||
|
reject(new Error())
|
||||||
|
} else {
|
||||||
|
resolve(result as { suggestions: string[] })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addWord(word: string) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (this.hunspellManager) {
|
||||||
|
this.hunspellManager.send({ type: 'add_word', word }, result => {
|
||||||
|
if ((result as { error: true }).error) {
|
||||||
|
reject(new Error())
|
||||||
|
} else {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
removeWord(word: string) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (this.hunspellManager) {
|
||||||
|
this.hunspellManager.send({ type: 'remove_word', word }, result => {
|
||||||
|
if ((result as { error: true }).error) {
|
||||||
|
reject(new Error())
|
||||||
|
} else {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
_spellCheckWhenParserReady(view: EditorView) {
|
_spellCheckWhenParserReady(view: EditorView) {
|
||||||
if (this.waitingForParser) {
|
if (this.waitingForParser) {
|
||||||
return
|
return
|
||||||
|
@ -163,7 +229,7 @@ export class SpellChecker {
|
||||||
const { from, to } = view.viewport
|
const { from, to } = view.viewport
|
||||||
const changedLineNumbers = new Set<number>()
|
const changedLineNumbers = new Set<number>()
|
||||||
if (this.trackedChanges.length > 0) {
|
if (this.trackedChanges.length > 0) {
|
||||||
this.trackedChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
|
this.trackedChanges.iterChangedRanges((_fromA, _toA, fromB, toB) => {
|
||||||
if (fromB <= to && toB >= from) {
|
if (fromB <= to && toB >= from) {
|
||||||
const fromLine = view.state.doc.lineAt(fromB).number
|
const fromLine = view.state.doc.lineAt(fromB).number
|
||||||
const toLine = view.state.doc.lineAt(toB).number
|
const toLine = view.state.doc.lineAt(toB).number
|
||||||
|
@ -180,7 +246,9 @@ export class SpellChecker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ignoredWords = view.state.field(ignoredWordsField)
|
const ignoredWords = this.hunspellManager
|
||||||
|
? null
|
||||||
|
: view.state.field(ignoredWordsField)
|
||||||
for (const i of changedLineNumbers) {
|
for (const i of changedLineNumbers) {
|
||||||
const line = view.state.doc.line(i)
|
const line = view.state.doc.line(i)
|
||||||
wordsToCheck.push(
|
wordsToCheck.push(
|
||||||
|
@ -228,7 +296,7 @@ export class Word {
|
||||||
export const buildSpellCheckResult = (
|
export const buildSpellCheckResult = (
|
||||||
knownMisspelledWords: Word[],
|
knownMisspelledWords: Word[],
|
||||||
unknownWords: Word[],
|
unknownWords: Word[],
|
||||||
misspellings: { index: number; suggestions: string[] }[]
|
misspellings: { index: number; suggestions?: string[] }[]
|
||||||
) => {
|
) => {
|
||||||
const cacheAdditions: [Word, string[] | boolean][] = []
|
const cacheAdditions: [Word, string[] | boolean][] = []
|
||||||
|
|
||||||
|
@ -277,7 +345,7 @@ export const compileEffects = (results: {
|
||||||
export const getWordsFromLine = (
|
export const getWordsFromLine = (
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
line: Line,
|
line: Line,
|
||||||
ignoredWords: IgnoredWords,
|
ignoredWords: IgnoredWords | null,
|
||||||
lang: string
|
lang: string
|
||||||
): Word[] => {
|
): Word[] => {
|
||||||
const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
|
const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
|
||||||
|
@ -287,14 +355,8 @@ export const getWordsFromLine = (
|
||||||
const words: Word[] = []
|
const words: Word[] = []
|
||||||
for (const span of normalTextSpans) {
|
for (const span of normalTextSpans) {
|
||||||
for (const match of span.text.matchAll(WORD_REGEX)) {
|
for (const match of span.text.matchAll(WORD_REGEX)) {
|
||||||
let word = match[0]
|
const word = match[0].replace(/^'+/, '').replace(/'+$/, '')
|
||||||
if (word.startsWith("'")) {
|
if (!ignoredWords?.has(word)) {
|
||||||
word = word.slice(1)
|
|
||||||
}
|
|
||||||
if (word.endsWith("'")) {
|
|
||||||
word = word.slice(0, -1)
|
|
||||||
}
|
|
||||||
if (!ignoredWords.has(word)) {
|
|
||||||
const from = span.from + match.index
|
const from = span.from + match.index
|
||||||
words.push(
|
words.push(
|
||||||
new Word({
|
new Word({
|
||||||
|
|
|
@ -0,0 +1,189 @@
|
||||||
|
import {
|
||||||
|
FC,
|
||||||
|
MouseEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { SpellChecker, Word } from './spellchecker'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import SplitTestBadge from '@/shared/components/split-test-badge'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import { SpellCheckLanguage } from '../../../../../../types/project-settings'
|
||||||
|
import Icon from '@/shared/components/icon'
|
||||||
|
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||||
|
import { sendMB } from '@/infrastructure/event-tracking'
|
||||||
|
|
||||||
|
const ITEMS_TO_SHOW = 8
|
||||||
|
|
||||||
|
// TODO: messaging below the spelling suggestions
|
||||||
|
const SHOW_FOOTER = false
|
||||||
|
|
||||||
|
// (index % length) that works for negative index
|
||||||
|
const wrapArrayIndex = (index: number, length: number) =>
|
||||||
|
((index % length) + length) % length
|
||||||
|
|
||||||
|
export const SpellingSuggestions: FC<{
|
||||||
|
word: Word
|
||||||
|
spellCheckLanguage?: string
|
||||||
|
spellChecker?: SpellChecker | null
|
||||||
|
handleClose: () => void
|
||||||
|
handleLearnWord: () => void
|
||||||
|
handleCorrectWord: (text: string) => void
|
||||||
|
}> = ({
|
||||||
|
word,
|
||||||
|
spellCheckLanguage,
|
||||||
|
spellChecker,
|
||||||
|
handleClose,
|
||||||
|
handleLearnWord,
|
||||||
|
handleCorrectWord,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [suggestions, setSuggestions] = useState(() =>
|
||||||
|
Array.isArray(word.suggestions)
|
||||||
|
? word.suggestions.slice(0, ITEMS_TO_SHOW)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
|
const [waiting, setWaiting] = useState(!word.suggestions)
|
||||||
|
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
|
||||||
|
const itemsLength = suggestions.length + 1
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!word.suggestions) {
|
||||||
|
spellChecker?.suggest(word.text).then(result => {
|
||||||
|
setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW))
|
||||||
|
setWaiting(false)
|
||||||
|
sendMB('spelling-suggestion-shown', {
|
||||||
|
language: spellCheckLanguage,
|
||||||
|
count: result.suggestions.length,
|
||||||
|
// word: transaction.state.sliceDoc(mark.from, mark.to),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [word, spellChecker, spellCheckLanguage])
|
||||||
|
|
||||||
|
const language = useMemo(() => {
|
||||||
|
if (spellCheckLanguage) {
|
||||||
|
return getMeta('ol-languages').find(
|
||||||
|
item => item.code === spellCheckLanguage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [spellCheckLanguage])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className={classnames('dropdown-menu', 'dropdown-menu-unpositioned', {
|
||||||
|
hidden: waiting,
|
||||||
|
})}
|
||||||
|
tabIndex={0}
|
||||||
|
role="menu"
|
||||||
|
onKeyDown={event => {
|
||||||
|
switch (event.code) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
setSelectedIndex(value => wrapArrayIndex(value + 1, itemsLength))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
setSelectedIndex(value => wrapArrayIndex(value - 1, itemsLength))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
case 'Tab':
|
||||||
|
event.preventDefault()
|
||||||
|
handleClose()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.isArray(suggestions) && (
|
||||||
|
<>
|
||||||
|
{suggestions.map((suggestion, index) => (
|
||||||
|
<ListItem
|
||||||
|
key={suggestion}
|
||||||
|
content={suggestion}
|
||||||
|
selected={index === selectedIndex}
|
||||||
|
handleClick={event => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleCorrectWord(suggestion)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{suggestions.length > 0 && <li className="divider" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ListItem
|
||||||
|
content={t('add_to_dictionary')}
|
||||||
|
selected={selectedIndex === itemsLength - 1}
|
||||||
|
handleClick={event => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleLearnWord()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{SHOW_FOOTER && language && (
|
||||||
|
<>
|
||||||
|
<li className="divider" />
|
||||||
|
<li>
|
||||||
|
<Footer language={language} />
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Footer: FC<{ language: SpellCheckLanguage }> = ({ language }) => {
|
||||||
|
const spellCheckClientEnabled = useFeatureFlag('spell-check-client')
|
||||||
|
|
||||||
|
if (!spellCheckClientEnabled) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return language.dic ? (
|
||||||
|
<div>
|
||||||
|
<SplitTestBadge
|
||||||
|
splitTestName="spell-check-client"
|
||||||
|
displayOnVariants={['enabled']}
|
||||||
|
/>{' '}
|
||||||
|
<small>{language?.name}</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Icon type="warning" fw /> <small>{language?.name}</small>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListItem: FC<{
|
||||||
|
content: string
|
||||||
|
selected: boolean
|
||||||
|
handleClick: MouseEventHandler<HTMLButtonElement>
|
||||||
|
}> = ({ content, selected, handleClick }) => {
|
||||||
|
const handleListItem = useCallback(
|
||||||
|
(element: HTMLElement | null) => {
|
||||||
|
if (element && selected) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
element.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selected]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li role="menuitem">
|
||||||
|
<button
|
||||||
|
className="btn-link text-left dropdown-menu-button"
|
||||||
|
onClick={handleClick}
|
||||||
|
ref={handleListItem}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
|
@ -37,7 +37,7 @@ import {
|
||||||
addLearnedWord,
|
addLearnedWord,
|
||||||
removeLearnedWord,
|
removeLearnedWord,
|
||||||
resetLearnedWords,
|
resetLearnedWords,
|
||||||
setSpelling,
|
setSpellCheckLanguage,
|
||||||
} from '../extensions/spelling'
|
} from '../extensions/spelling'
|
||||||
import {
|
import {
|
||||||
createChangeManager,
|
createChangeManager,
|
||||||
|
@ -65,6 +65,7 @@ import { setMathPreview } from '@/features/source-editor/extensions/math-preview
|
||||||
import { useRangesContext } from '@/features/review-panel-new/context/ranges-context'
|
import { useRangesContext } from '@/features/review-panel-new/context/ranges-context'
|
||||||
import { updateRanges } from '@/features/source-editor/extensions/ranges'
|
import { updateRanges } from '@/features/source-editor/extensions/ranges'
|
||||||
import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
|
import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
|
||||||
|
import { useHunspell } from '@/features/source-editor/hooks/use-hunspell'
|
||||||
|
|
||||||
function useCodeMirrorScope(view: EditorView) {
|
function useCodeMirrorScope(view: EditorView) {
|
||||||
const { fileTreeData } = useFileTreeData()
|
const { fileTreeData } = useFileTreeData()
|
||||||
|
@ -111,6 +112,8 @@ function useCodeMirrorScope(view: EditorView) {
|
||||||
'project.spellCheckLanguage'
|
'project.spellCheckLanguage'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hunspellManager = useHunspell(spellCheckLanguage)
|
||||||
|
|
||||||
const [visual] = useScopeValue<boolean>('editor.showVisual')
|
const [visual] = useScopeValue<boolean>('editor.showVisual')
|
||||||
|
|
||||||
const { referenceKeys } = useReferencesContext()
|
const { referenceKeys } = useReferencesContext()
|
||||||
|
@ -214,14 +217,16 @@ function useCodeMirrorScope(view: EditorView) {
|
||||||
|
|
||||||
const spellingRef = useRef({
|
const spellingRef = useRef({
|
||||||
spellCheckLanguage,
|
spellCheckLanguage,
|
||||||
|
hunspellManager,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
spellingRef.current = {
|
spellingRef.current = {
|
||||||
spellCheckLanguage,
|
spellCheckLanguage,
|
||||||
|
hunspellManager,
|
||||||
}
|
}
|
||||||
view.dispatch(setSpelling(spellingRef.current))
|
view.dispatch(setSpellCheckLanguage(spellingRef.current))
|
||||||
}, [view, spellCheckLanguage])
|
}, [view, spellCheckLanguage, hunspellManager])
|
||||||
|
|
||||||
// listen to doc:after-opened, and focus the editor if it's not a new doc
|
// listen to doc:after-opened, and focus the editor if it's not a new doc
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import { globalLearnedWords } from '@/features/dictionary/ignored-words'
|
||||||
|
import { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
|
||||||
|
export const useHunspell = (spellCheckLanguage: string | null) => {
|
||||||
|
const [hunspellManager, setHunspellManager] = useState<HunspellManager>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSplitTestEnabled('spell-check-client')) {
|
||||||
|
if (spellCheckLanguage) {
|
||||||
|
const languages = getMeta('ol-languages')
|
||||||
|
const lang = languages.find(item => item.code === spellCheckLanguage)
|
||||||
|
if (lang?.dic) {
|
||||||
|
const hunspellManager = new HunspellManager(lang.dic, [
|
||||||
|
...globalLearnedWords,
|
||||||
|
...getMeta('ol-learnedWords'),
|
||||||
|
])
|
||||||
|
setHunspellManager(hunspellManager)
|
||||||
|
debugConsole.log(spellCheckLanguage, hunspellManager)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
hunspellManager.destroy()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setHunspellManager(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [spellCheckLanguage])
|
||||||
|
|
||||||
|
return hunspellManager
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
FROM emscripten/emsdk:3.1.67
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
autoconf \
|
||||||
|
automake \
|
||||||
|
autopoint \
|
||||||
|
build-essential \
|
||||||
|
libtool \
|
||||||
|
cmake
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
|
import { createWorker } from '@/utils/worker'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
|
||||||
|
type Message =
|
||||||
|
| {
|
||||||
|
id?: string
|
||||||
|
type: 'spell'
|
||||||
|
words: string[]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id?: string
|
||||||
|
type: 'suggest'
|
||||||
|
word: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id?: string
|
||||||
|
type: 'add_word'
|
||||||
|
word: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id?: string
|
||||||
|
type: 'remove_word'
|
||||||
|
word: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id?: string
|
||||||
|
type: 'destroy'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HunspellManager {
|
||||||
|
dictionariesRoot: string
|
||||||
|
hunspellWorker!: Worker
|
||||||
|
abortController: AbortController | undefined
|
||||||
|
listening = false
|
||||||
|
loaded = false
|
||||||
|
pendingMessages: Message[] = []
|
||||||
|
callbacks: Map<string, (value: unknown) => void> = new Map()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly language: string,
|
||||||
|
private readonly learnedWords: string[]
|
||||||
|
) {
|
||||||
|
const baseAssetPath = new URL(
|
||||||
|
getMeta('ol-baseAssetPath'),
|
||||||
|
window.location.href
|
||||||
|
)
|
||||||
|
|
||||||
|
this.dictionariesRoot = new URL(
|
||||||
|
getMeta('ol-dictionariesRoot'),
|
||||||
|
baseAssetPath
|
||||||
|
).toString()
|
||||||
|
|
||||||
|
createWorker(() => {
|
||||||
|
this.hunspellWorker = new Worker(
|
||||||
|
new URL('./hunspell.worker.ts', import.meta.url),
|
||||||
|
{ type: 'module' }
|
||||||
|
)
|
||||||
|
|
||||||
|
this.hunspellWorker.addEventListener('message', this.receive.bind(this))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.send({ type: 'destroy' }, () => {
|
||||||
|
this.hunspellWorker.terminate()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: Message, callback: (value: unknown) => void) {
|
||||||
|
debugConsole.log(message)
|
||||||
|
if (callback) {
|
||||||
|
message.id = uuid()
|
||||||
|
this.callbacks.set(message.id, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.listening) {
|
||||||
|
this.hunspellWorker.postMessage(message)
|
||||||
|
} else {
|
||||||
|
this.pendingMessages.push(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
receive(event: MessageEvent) {
|
||||||
|
debugConsole.log(event.data)
|
||||||
|
const { id, listening, loaded, ...rest } = event.data
|
||||||
|
if (id) {
|
||||||
|
const callback = this.callbacks.get(id)
|
||||||
|
if (callback) {
|
||||||
|
this.callbacks.delete(id)
|
||||||
|
callback(rest)
|
||||||
|
}
|
||||||
|
} else if (listening) {
|
||||||
|
this.listening = true
|
||||||
|
this.hunspellWorker.postMessage({
|
||||||
|
type: 'init',
|
||||||
|
lang: this.language,
|
||||||
|
learnedWords: this.learnedWords, // TODO: add words
|
||||||
|
dictionariesRoot: this.dictionariesRoot,
|
||||||
|
})
|
||||||
|
for (const message of this.pendingMessages) {
|
||||||
|
this.hunspellWorker.postMessage(message)
|
||||||
|
this.pendingMessages.length = 0
|
||||||
|
}
|
||||||
|
} else if (loaded) {
|
||||||
|
this.loaded = true
|
||||||
|
// TODO: use this to display pending state?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
services/web/frontend/js/features/source-editor/hunspell/build.sh
Executable file
13
services/web/frontend/js/features/source-editor/hunspell/build.sh
Executable file
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# build an Emscripten SDK Docker image with Hunspell's build dependencies installed
|
||||||
|
docker build --pull --tag overleaf/emsdk .
|
||||||
|
|
||||||
|
# compile Hunspell to WASM and copy the output files from the Docker container
|
||||||
|
docker run --rm \
|
||||||
|
--workdir /opt \
|
||||||
|
--volume "$(pwd)/wasm":/wasm \
|
||||||
|
--volume "$(pwd)/compile.sh":/opt/compile.sh:ro \
|
||||||
|
overleaf/emsdk \
|
||||||
|
bash compile.sh
|
24
services/web/frontend/js/features/source-editor/hunspell/compile.sh
Executable file
24
services/web/frontend/js/features/source-editor/hunspell/compile.sh
Executable file
|
@ -0,0 +1,24 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
COMMIT="e994dceb97fb695bca6bfe5ad5665525426bf01f"
|
||||||
|
|
||||||
|
curl -L "https://github.com/hunspell/hunspell/archive/${COMMIT}.tar.gz" | tar xvz
|
||||||
|
|
||||||
|
cd "hunspell-${COMMIT}"
|
||||||
|
autoreconf -fiv
|
||||||
|
emconfigure ./configure --disable-shared --enable-static
|
||||||
|
emmake make
|
||||||
|
|
||||||
|
em++ \
|
||||||
|
-s EXPORTED_FUNCTIONS="['_Hunspell_create', '_Hunspell_destroy', '_Hunspell_spell', '_Hunspell_suggest', '_Hunspell_free_list', '_Hunspell_add_dic', '_Hunspell_add', '_Hunspell_remove', '_free', '_malloc', 'FS']" \
|
||||||
|
-s EXPORTED_RUNTIME_METHODS="['ccall', 'cwrap', 'getValue', 'stringToNewUTF8', 'UTF8ToString', 'WORKERFS']" \
|
||||||
|
-s ENVIRONMENT=worker \
|
||||||
|
-s ALLOW_MEMORY_GROWTH \
|
||||||
|
-lworkerfs.js \
|
||||||
|
-O2 \
|
||||||
|
-g2 \
|
||||||
|
src/hunspell/.libs/libhunspell-1.7.a \
|
||||||
|
-o hunspell.mjs
|
||||||
|
|
||||||
|
cp hunspell.{mjs,wasm} /wasm/
|
|
@ -0,0 +1,237 @@
|
||||||
|
import Hunspell from './wasm/hunspell'
|
||||||
|
|
||||||
|
type SpellChecker = {
|
||||||
|
spell(words: string[]): { index: number }[]
|
||||||
|
suggest(word: string): string[]
|
||||||
|
addWord(word: string): void
|
||||||
|
removeWord(word: string): void
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSpellChecker = async ({
|
||||||
|
lang,
|
||||||
|
learnedWords,
|
||||||
|
dictionariesRoot,
|
||||||
|
}: {
|
||||||
|
lang: string
|
||||||
|
learnedWords: string[]
|
||||||
|
dictionariesRoot: string
|
||||||
|
}) => {
|
||||||
|
const hunspell = await Hunspell()
|
||||||
|
|
||||||
|
const {
|
||||||
|
cwrap,
|
||||||
|
FS,
|
||||||
|
WORKERFS,
|
||||||
|
stringToNewUTF8,
|
||||||
|
_malloc,
|
||||||
|
_free,
|
||||||
|
getValue,
|
||||||
|
UTF8ToString,
|
||||||
|
} = hunspell
|
||||||
|
|
||||||
|
// https://github.com/hunspell/hunspell/blob/master/src/hunspell/hunspell.h
|
||||||
|
// https://github.com/kwonoj/hunspell-asm/blob/master/src/wrapHunspellInterface.ts
|
||||||
|
|
||||||
|
const create = cwrap('Hunspell_create', 'number', ['number', 'number'])
|
||||||
|
const destroy = cwrap('Hunspell_destroy', 'number', ['number', 'number'])
|
||||||
|
const spell = cwrap('Hunspell_spell', 'number', ['number', 'number'])
|
||||||
|
const suggest = cwrap('Hunspell_suggest', 'number', [
|
||||||
|
'number',
|
||||||
|
'number',
|
||||||
|
'number',
|
||||||
|
])
|
||||||
|
const addWord = cwrap('Hunspell_add', 'number', ['number', 'number'])
|
||||||
|
const removeWord = cwrap('Hunspell_remove', 'number', ['number', 'number'])
|
||||||
|
const freeList = cwrap('Hunspell_free_list', 'number', [
|
||||||
|
'number',
|
||||||
|
'number',
|
||||||
|
'number',
|
||||||
|
])
|
||||||
|
|
||||||
|
FS.mkdir('/dictionaries')
|
||||||
|
|
||||||
|
const [dic, aff] = await Promise.all([
|
||||||
|
fetch(new URL(`./${lang}.dic`, dictionariesRoot)).then(response =>
|
||||||
|
response.blob()
|
||||||
|
),
|
||||||
|
fetch(new URL(`./${lang}.aff`, dictionariesRoot)).then(response =>
|
||||||
|
response.blob()
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
FS.mount(
|
||||||
|
WORKERFS,
|
||||||
|
{
|
||||||
|
blobs: [
|
||||||
|
{ name: 'index.dic', data: dic },
|
||||||
|
{ name: 'index.aff', data: aff },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'/dictionaries'
|
||||||
|
)
|
||||||
|
|
||||||
|
const dicPtr = stringToNewUTF8('/dictionaries/index.dic')
|
||||||
|
const affPtr = stringToNewUTF8('/dictionaries/index.aff')
|
||||||
|
const spellPtr = create(affPtr, dicPtr)
|
||||||
|
|
||||||
|
for (const word of learnedWords) {
|
||||||
|
const wordPtr = stringToNewUTF8(word)
|
||||||
|
addWord(spellPtr, wordPtr)
|
||||||
|
_free(wordPtr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const spellChecker: SpellChecker = {
|
||||||
|
spell(words) {
|
||||||
|
const misspellings: { index: number }[] = []
|
||||||
|
|
||||||
|
for (const [index, word] of words.entries()) {
|
||||||
|
const wordPtr = stringToNewUTF8(word)
|
||||||
|
const spellResult = spell(spellPtr, wordPtr)
|
||||||
|
_free(wordPtr)
|
||||||
|
|
||||||
|
if (spellResult === 0) {
|
||||||
|
misspellings.push({ index })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return misspellings
|
||||||
|
},
|
||||||
|
suggest(word) {
|
||||||
|
const suggestions: string[] = []
|
||||||
|
|
||||||
|
const suggestionListPtr = _malloc(4)
|
||||||
|
const wordPtr = stringToNewUTF8(word)
|
||||||
|
const suggestionCount = suggest(spellPtr, suggestionListPtr, wordPtr)
|
||||||
|
_free(wordPtr)
|
||||||
|
const suggestionListValuePtr = getValue(suggestionListPtr, '*')
|
||||||
|
|
||||||
|
for (let i = 0; i < suggestionCount; i++) {
|
||||||
|
const suggestion = UTF8ToString(
|
||||||
|
getValue(suggestionListValuePtr + i * 4, '*')
|
||||||
|
)
|
||||||
|
suggestions.push(suggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
freeList(spellPtr, suggestionListPtr, suggestionCount)
|
||||||
|
_free(suggestionListPtr)
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
},
|
||||||
|
addWord(word) {
|
||||||
|
const wordPtr = stringToNewUTF8(word)
|
||||||
|
const result = addWord(spellPtr, wordPtr)
|
||||||
|
_free(wordPtr)
|
||||||
|
|
||||||
|
if (result !== 0) {
|
||||||
|
throw new Error('The word could not be added to the dictionary')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeWord(word) {
|
||||||
|
const wordPtr = stringToNewUTF8(word)
|
||||||
|
const result = removeWord(spellPtr, wordPtr)
|
||||||
|
_free(wordPtr)
|
||||||
|
|
||||||
|
if (result !== 0) {
|
||||||
|
throw new Error('The word could not be removed from the dictionary')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
destroy(spellPtr)
|
||||||
|
_free(spellPtr)
|
||||||
|
_free(dicPtr)
|
||||||
|
_free(affPtr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return spellChecker
|
||||||
|
}
|
||||||
|
|
||||||
|
let spellCheckerPromise: Promise<SpellChecker>
|
||||||
|
|
||||||
|
self.addEventListener('message', async event => {
|
||||||
|
switch (event.data.type) {
|
||||||
|
case 'init':
|
||||||
|
try {
|
||||||
|
spellCheckerPromise = createSpellChecker(event.data)
|
||||||
|
await spellCheckerPromise
|
||||||
|
self.postMessage({ loaded: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
self.postMessage({ error: true })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'spell':
|
||||||
|
{
|
||||||
|
const { id, words } = event.data
|
||||||
|
try {
|
||||||
|
const spellChecker = await spellCheckerPromise
|
||||||
|
const misspellings = spellChecker.spell(words)
|
||||||
|
self.postMessage({ id, misspellings })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
self.postMessage({ id, error: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'suggest':
|
||||||
|
{
|
||||||
|
const { id, word } = event.data
|
||||||
|
try {
|
||||||
|
const spellChecker = await spellCheckerPromise
|
||||||
|
const suggestions = spellChecker.suggest(word)
|
||||||
|
self.postMessage({ id, suggestions })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
self.postMessage({ id, error: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'add_word':
|
||||||
|
{
|
||||||
|
const { id, word } = event.data
|
||||||
|
try {
|
||||||
|
const spellChecker = await spellCheckerPromise
|
||||||
|
spellChecker.addWord(word)
|
||||||
|
self.postMessage({ id })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
self.postMessage({ id, error: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'remove_word':
|
||||||
|
{
|
||||||
|
const { id, word } = event.data
|
||||||
|
try {
|
||||||
|
const spellChecker = await spellCheckerPromise
|
||||||
|
spellChecker.removeWord(word)
|
||||||
|
self.postMessage({ id })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
self.postMessage({ id, error: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'destroy':
|
||||||
|
{
|
||||||
|
const { id } = event.data
|
||||||
|
try {
|
||||||
|
const spellChecker = await spellCheckerPromise
|
||||||
|
spellChecker.destroy()
|
||||||
|
self.postMessage({ id })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
self.postMessage({ id, error: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
self.postMessage({ listening: true })
|
66
services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.d.ts
vendored
Normal file
66
services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.d.ts
vendored
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
/* eslint no-dupe-class-members: 0 */
|
||||||
|
|
||||||
|
declare class Hunspell {
|
||||||
|
cwrap(
|
||||||
|
method: 'Hunspell_create',
|
||||||
|
output: string,
|
||||||
|
input: string[]
|
||||||
|
): (affPtr: number, dicPtr: number) => number
|
||||||
|
|
||||||
|
cwrap(
|
||||||
|
method: 'Hunspell_destroy',
|
||||||
|
output: string,
|
||||||
|
input: string[]
|
||||||
|
): (spellPtr: number) => number
|
||||||
|
|
||||||
|
cwrap(
|
||||||
|
method: 'Hunspell_spell',
|
||||||
|
output: string,
|
||||||
|
input: string[]
|
||||||
|
): (spellPtr: number, wordPtr: number) => number
|
||||||
|
|
||||||
|
cwrap(
|
||||||
|
method: 'Hunspell_suggest',
|
||||||
|
output: string,
|
||||||
|
input: string[]
|
||||||
|
): (spellPtr: number, suggestionListPtr: number, wordPtr: number) => number
|
||||||
|
|
||||||
|
cwrap(
|
||||||
|
method: 'Hunspell_add',
|
||||||
|
output: string,
|
||||||
|
input: string[]
|
||||||
|
): (spellPtr: number, wordPtr: number) => number
|
||||||
|
|
||||||
|
cwrap(
|
||||||
|
method: 'Hunspell_remove',
|
||||||
|
output: string,
|
||||||
|
input: string[]
|
||||||
|
): (spellPtr: number, wordPtr: number) => number
|
||||||
|
|
||||||
|
cwrap(
|
||||||
|
method: 'Hunspell_free_list',
|
||||||
|
output: string,
|
||||||
|
input: string[]
|
||||||
|
): (spellPtr: number, suggestionListPtr: number, n: number) => number
|
||||||
|
|
||||||
|
stringToNewUTF8(input: string): number
|
||||||
|
UTF8ToString(input: number): string
|
||||||
|
_malloc(length: number): number
|
||||||
|
_free(ptr: number): void
|
||||||
|
getValue(ptr: number, type: string): number
|
||||||
|
FS: {
|
||||||
|
mkdir(path: string): void
|
||||||
|
mount(
|
||||||
|
type: any,
|
||||||
|
data: { blobs: Record<{ name: string; data: BlobPart }>[] },
|
||||||
|
dir: string
|
||||||
|
): void
|
||||||
|
}
|
||||||
|
|
||||||
|
WORKERFS: any
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const factory = async (options?: Record<string, any>) =>
|
||||||
|
new Hunspell(options)
|
||||||
|
|
||||||
|
export default factory
|
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
@ -54,6 +54,7 @@ export interface Meta {
|
||||||
'ol-allowedExperiments': string[]
|
'ol-allowedExperiments': string[]
|
||||||
'ol-allowedImageNames': AllowedImageName[]
|
'ol-allowedImageNames': AllowedImageName[]
|
||||||
'ol-anonymous': boolean
|
'ol-anonymous': boolean
|
||||||
|
'ol-baseAssetPath': string
|
||||||
'ol-bootstrapVersion': 3 | 5
|
'ol-bootstrapVersion': 3 | 5
|
||||||
'ol-brandVariation': Record<string, any>
|
'ol-brandVariation': Record<string, any>
|
||||||
|
|
||||||
|
@ -76,6 +77,7 @@ export interface Meta {
|
||||||
'ol-currentUrl': string
|
'ol-currentUrl': string
|
||||||
'ol-debugPdfDetach': boolean
|
'ol-debugPdfDetach': boolean
|
||||||
'ol-detachRole': 'detached' | 'detacher' | ''
|
'ol-detachRole': 'detached' | 'detacher' | ''
|
||||||
|
'ol-dictionariesRoot': 'string'
|
||||||
'ol-dropbox': { error: boolean; registered: boolean }
|
'ol-dropbox': { error: boolean; registered: boolean }
|
||||||
'ol-editorThemes': string[]
|
'ol-editorThemes': string[]
|
||||||
'ol-email': string
|
'ol-email': string
|
||||||
|
|
|
@ -77,6 +77,7 @@
|
||||||
"add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
|
"add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
|
||||||
"add_people": "Add people",
|
"add_people": "Add people",
|
||||||
"add_role_and_department": "Add role and department",
|
"add_role_and_department": "Add role and department",
|
||||||
|
"add_to_dictionary": "Add to Dictionary",
|
||||||
"add_to_tag": "Add to tag",
|
"add_to_tag": "Add to tag",
|
||||||
"add_your_comment_here": "Add your comment here",
|
"add_your_comment_here": "Add your comment here",
|
||||||
"add_your_first_group_member_now": "Add your first group members now",
|
"add_your_first_group_member_now": "Add your first group members now",
|
||||||
|
|
|
@ -76,6 +76,7 @@
|
||||||
"@node-oauth/oauth2-server": "^5.1.0",
|
"@node-oauth/oauth2-server": "^5.1.0",
|
||||||
"@node-saml/passport-saml": "^4.0.4",
|
"@node-saml/passport-saml": "^4.0.4",
|
||||||
"@overleaf/access-token-encryptor": "*",
|
"@overleaf/access-token-encryptor": "*",
|
||||||
|
"@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz",
|
||||||
"@overleaf/fetch-utils": "*",
|
"@overleaf/fetch-utils": "*",
|
||||||
"@overleaf/logger": "*",
|
"@overleaf/logger": "*",
|
||||||
"@overleaf/metrics": "*",
|
"@overleaf/metrics": "*",
|
||||||
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
import DictionaryModal from '@/features/dictionary/components/dictionary-modal'
|
||||||
|
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||||
|
|
||||||
|
describe('<DictionaryModalContent />', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
cy.interceptCompile()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('list words', function () {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.metaAttributesCache.set('ol-learnedWords', ['foo', 'bar'])
|
||||||
|
win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.mount(
|
||||||
|
<EditorProviders>
|
||||||
|
<DictionaryModal show handleHide={cy.stub()} />
|
||||||
|
</EditorProviders>
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.findByText('foo')
|
||||||
|
cy.findByText('bar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows message when empty', function () {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.metaAttributesCache.set('ol-learnedWords', [])
|
||||||
|
win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.mount(
|
||||||
|
<EditorProviders>
|
||||||
|
<DictionaryModal show handleHide={cy.stub()} />
|
||||||
|
</EditorProviders>
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.contains('Your custom dictionary is empty.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes words', function () {
|
||||||
|
cy.intercept('/spelling/unlearn', { statusCode: 200 })
|
||||||
|
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.metaAttributesCache.set('ol-learnedWords', ['Foo', 'bar'])
|
||||||
|
win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.mount(
|
||||||
|
<EditorProviders>
|
||||||
|
<DictionaryModal show handleHide={cy.stub()} />
|
||||||
|
</EditorProviders>
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.findByText('Foo')
|
||||||
|
cy.findByText('bar')
|
||||||
|
|
||||||
|
cy.findAllByRole('button', {
|
||||||
|
name: 'Remove from dictionary',
|
||||||
|
})
|
||||||
|
.eq(0)
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.findByText('bar').should('not.exist')
|
||||||
|
cy.findByText('Foo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles errors', function () {
|
||||||
|
cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn')
|
||||||
|
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.metaAttributesCache.set('ol-learnedWords', ['foo'])
|
||||||
|
win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.mount(
|
||||||
|
<EditorProviders>
|
||||||
|
<DictionaryModal show handleHide={cy.stub()} />
|
||||||
|
</EditorProviders>
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.findByText('foo')
|
||||||
|
|
||||||
|
cy.findAllByRole('button', {
|
||||||
|
name: 'Remove from dictionary',
|
||||||
|
})
|
||||||
|
.eq(0)
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.wait('@unlearn')
|
||||||
|
|
||||||
|
cy.findByText('Sorry, something went wrong')
|
||||||
|
cy.findByText('foo')
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,60 +0,0 @@
|
||||||
import {
|
|
||||||
screen,
|
|
||||||
fireEvent,
|
|
||||||
waitForElementToBeRemoved,
|
|
||||||
} from '@testing-library/react'
|
|
||||||
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('<DictionaryModalContent />', function () {
|
|
||||||
afterEach(function () {
|
|
||||||
fetchMock.reset()
|
|
||||||
setLearnedWords([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('list words', async function () {
|
|
||||||
setLearnedWords(['foo', 'bar'])
|
|
||||||
renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
|
|
||||||
screen.getByText('foo')
|
|
||||||
screen.getByText('bar')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows message when empty', async function () {
|
|
||||||
setLearnedWords([])
|
|
||||||
renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
|
|
||||||
screen.getByText('Your custom dictionary is empty.')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('removes words', async function () {
|
|
||||||
fetchMock.post('/spelling/unlearn', 200)
|
|
||||||
setLearnedWords(['Foo', 'bar'])
|
|
||||||
renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
|
|
||||||
screen.getByText('Foo')
|
|
||||||
screen.getByText('bar')
|
|
||||||
const [firstButton] = screen.getAllByRole('button', {
|
|
||||||
name: 'Remove from dictionary',
|
|
||||||
})
|
|
||||||
fireEvent.click(firstButton)
|
|
||||||
await waitForElementToBeRemoved(() => screen.getByText('bar'))
|
|
||||||
screen.getByText('Foo')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles errors', async function () {
|
|
||||||
fetchMock.post('/spelling/unlearn', 500)
|
|
||||||
setLearnedWords(['foo'])
|
|
||||||
renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
|
|
||||||
screen.getByText('foo')
|
|
||||||
const [firstButton] = screen.getAllByRole('button', {
|
|
||||||
name: 'Remove from dictionary',
|
|
||||||
})
|
|
||||||
fireEvent.click(firstButton)
|
|
||||||
await fetchMock.flush()
|
|
||||||
screen.getByText('Sorry, something went wrong')
|
|
||||||
screen.getByText('foo')
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
import '../../../helpers/bootstrap-3'
|
||||||
|
import { mockScope } from '../helpers/mock-scope'
|
||||||
|
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||||
|
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
|
||||||
|
import { TestContainer } from '../helpers/test-container'
|
||||||
|
import forEach from 'mocha-each'
|
||||||
|
import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions'
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: 'en_GB', dic: 'en_GB', name: 'English (British)' },
|
||||||
|
{ code: 'fr', dic: 'fr', name: 'French' },
|
||||||
|
{ code: 'sv', dic: 'sv_SE', name: 'Swedish' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const suggestions = {
|
||||||
|
en_GB: ['medecine', 'medicine'],
|
||||||
|
fr: ['medecin', 'médecin'],
|
||||||
|
sv: ['medecin', 'medicin'],
|
||||||
|
}
|
||||||
|
|
||||||
|
forEach(Object.keys(suggestions)).describe(
|
||||||
|
'Spell check in client (%s)',
|
||||||
|
(spellCheckLanguage: keyof typeof suggestions) => {
|
||||||
|
const content = `
|
||||||
|
\\documentclass{}
|
||||||
|
|
||||||
|
\\title{}
|
||||||
|
\\author{}
|
||||||
|
|
||||||
|
\\begin{document}
|
||||||
|
\\maketitle
|
||||||
|
|
||||||
|
\\begin{abstract}
|
||||||
|
\\end{abstract}
|
||||||
|
|
||||||
|
\\section{}
|
||||||
|
|
||||||
|
\\end{document}`
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||||
|
win.metaAttributesCache.set('ol-splitTestVariants', {
|
||||||
|
'spell-check-client': 'enabled',
|
||||||
|
})
|
||||||
|
win.metaAttributesCache.set('ol-splitTestInfo', {})
|
||||||
|
win.metaAttributesCache.set('ol-learnedWords', ['baz'])
|
||||||
|
win.metaAttributesCache.set(
|
||||||
|
'ol-dictionariesRoot',
|
||||||
|
`js/dictionaries/${PackageVersions.version.dictionaries}/`
|
||||||
|
)
|
||||||
|
win.metaAttributesCache.set('ol-baseAssetPath', '/__cypress/src/')
|
||||||
|
win.metaAttributesCache.set('ol-languages', languages)
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.interceptEvents()
|
||||||
|
|
||||||
|
const scope = mockScope(content)
|
||||||
|
scope.project.spellCheckLanguage = spellCheckLanguage
|
||||||
|
|
||||||
|
cy.mount(
|
||||||
|
<TestContainer>
|
||||||
|
<EditorProviders scope={scope}>
|
||||||
|
<CodeMirrorEditor />
|
||||||
|
</EditorProviders>
|
||||||
|
</TestContainer>
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.get('.cm-line').eq(13).as('line')
|
||||||
|
cy.get('@line').click()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows suggestions for misspelled word', function () {
|
||||||
|
const [from, to] = suggestions[spellCheckLanguage]
|
||||||
|
|
||||||
|
cy.get('@line').type(from)
|
||||||
|
cy.get('@line').get('.ol-cm-spelling-error').contains(from)
|
||||||
|
|
||||||
|
cy.get('@line').get('.ol-cm-spelling-error').rightclick()
|
||||||
|
cy.findByText(to).click()
|
||||||
|
cy.get('@line').contains(to)
|
||||||
|
cy.get('@line').find('.ol-cm-spelling-error').should('not.exist')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
|
@ -30,4 +30,6 @@ export type PdfViewer = 'pdfjs' | 'native'
|
||||||
export type SpellCheckLanguage = {
|
export type SpellCheckLanguage = {
|
||||||
name: string
|
name: string
|
||||||
code: string
|
code: string
|
||||||
|
dic?: string
|
||||||
|
server?: false
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,6 +66,7 @@ function getModuleDirectory(moduleName) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mathjaxDir = getModuleDirectory('mathjax')
|
const mathjaxDir = getModuleDirectory('mathjax')
|
||||||
|
const dictionariesDir = getModuleDirectory('@overleaf/dictionaries')
|
||||||
|
|
||||||
const pdfjsVersions = ['pdfjs-dist213', 'pdfjs-dist401']
|
const pdfjsVersions = ['pdfjs-dist213', 'pdfjs-dist401']
|
||||||
|
|
||||||
|
@ -78,6 +79,14 @@ if (MATHJAX_VERSION !== PackageVersions.version.mathjax) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DICTIONARIES_VERSION =
|
||||||
|
require('@overleaf/dictionaries/package.json').version
|
||||||
|
if (DICTIONARIES_VERSION !== PackageVersions.version.dictionaries) {
|
||||||
|
throw new Error(
|
||||||
|
'"@overleaf/dictionaries" version de-synced, update services/web/app/src/infrastructure/PackageVersions.js'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// Defines the "entry point(s)" for the application - i.e. the file which
|
// Defines the "entry point(s)" for the application - i.e. the file which
|
||||||
// bootstraps the application
|
// bootstraps the application
|
||||||
|
@ -141,6 +150,13 @@ module.exports = {
|
||||||
],
|
],
|
||||||
type: 'javascript/auto',
|
type: 'javascript/auto',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.wasm$/,
|
||||||
|
type: 'asset/resource',
|
||||||
|
generator: {
|
||||||
|
filename: 'js/[name]-[contenthash][ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Pass Less files through less-loader/css-loader/mini-css-extract-
|
// Pass Less files through less-loader/css-loader/mini-css-extract-
|
||||||
// plugin (note: run in reverse order)
|
// plugin (note: run in reverse order)
|
||||||
|
@ -275,6 +291,10 @@ module.exports = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
experiments: {
|
||||||
|
asyncWebAssembly: true,
|
||||||
|
},
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
new LezerGrammarCompilerPlugin(),
|
new LezerGrammarCompilerPlugin(),
|
||||||
|
|
||||||
|
@ -346,6 +366,12 @@ module.exports = {
|
||||||
toType: 'dir',
|
toType: 'dir',
|
||||||
context: mathjaxDir,
|
context: mathjaxDir,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
from: '*',
|
||||||
|
to: `js/dictionaries/${PackageVersions.version.dictionaries}`,
|
||||||
|
toType: 'dir',
|
||||||
|
context: `${dictionariesDir}/dictionaries`,
|
||||||
|
},
|
||||||
...pdfjsVersions.flatMap(version => {
|
...pdfjsVersions.flatMap(version => {
|
||||||
const dir = getModuleDirectory(version)
|
const dir = getModuleDirectory(version)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue