diff --git a/build.ts b/build.ts index 9dd903416..45d244594 100755 --- a/build.ts +++ b/build.ts @@ -66,12 +66,13 @@ const Plugin = { let defaultStrings: unknown; - let pairsResponse, analyzersResponse, generatorsResponse; + let pairsResponse, analyzersResponse, generatorsResponse, spellerResponse; try { - [pairsResponse, analyzersResponse, generatorsResponse] = await Promise.all([ + [pairsResponse, analyzersResponse, generatorsResponse, spellerResponse] = await Promise.all([ apyGet('list', {}), apyGet('list', { q: 'analyzers' }), apyGet('list', { q: 'generators' }), + apyGet('list', { q: 'spellers' }), ]); } catch (error) { let message = new String(error).toString(); @@ -104,6 +105,9 @@ const Plugin = { const generators = Object.fromEntries( Object.entries(generatorsResponse.data as Record).filter(([code]) => allowedLang(code)), ); + const spellers = Object.fromEntries( + Object.entries(spellerResponse.data as Record).filter(([code]) => allowedLang(code)), + ); let pairPrefs = {}; try { @@ -116,6 +120,7 @@ const Plugin = { ...pairs.flatMap(({ sourceLanguage, targetLanguage }) => [sourceLanguage, targetLanguage]), ...Object.keys(analyzers), ...Object.keys(generators), + ...Object.keys(spellers), ...Object.keys(languages), ...Object.keys(locales), ].filter(Boolean); @@ -170,6 +175,7 @@ const Plugin = { 'window.PAIR_PREFS': JSON.stringify(pairPrefs), 'window.ANALYZERS': JSON.stringify(analyzers), 'window.GENERATORS': JSON.stringify(generators), + 'window.SPELLERS': JSON.stringify(spellers), ...initialOptions.define, }; diff --git a/config.ts b/config.ts index 55939b288..cb8896f01 100644 --- a/config.ts +++ b/config.ts @@ -6,7 +6,7 @@ export default { apyURL: 'https://beta.apertium.org/apy', defaultMode: Mode.Translation, - enabledModes: new Set([Mode.Translation, Mode.Analysis, Mode.Generation, Mode.Sandbox]), + enabledModes: new Set([Mode.Translation, Mode.Analysis, Mode.Generation, Mode.Sandbox, Mode.SpellChecker]), translationChaining: true, subtitle: 'Beta', diff --git a/src/App.tsx b/src/App.tsx index 8780ccb22..9c798a6e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import Generator from './components/Generator'; import LocaleSelector from './components/LocaleSelector'; import Navbar from './components/navbar'; import Sandbox from './components/Sandbox'; +import SpellChecker from './components/spellchecker/SpellChecker'; import Translator from './components/translator/Translator'; import { Mode as TranslatorMode } from './components/translator'; import { Path as WebpageTranslationPath } from './components/translator/WebpageTranslationForm'; @@ -31,6 +32,7 @@ const Interfaces = { [Mode.Analysis]: Analyzer, [Mode.Generation]: Generator, [Mode.Sandbox]: Sandbox, + [Mode.SpellChecker]: SpellChecker, } as Record>; const App = ({ setLocale }: { setLocale: React.Dispatch> }): React.ReactElement => { diff --git a/src/components/navbar/__tests__/index.test.tsx b/src/components/navbar/__tests__/index.test.tsx index 4b4ff7c59..dcec6f0a8 100644 --- a/src/components/navbar/__tests__/index.test.tsx +++ b/src/components/navbar/__tests__/index.test.tsx @@ -33,7 +33,7 @@ describe('navigation options', () => { const navbar = screen.getByTestId('navbar-mobile'); const links = getAllByRole(navbar, 'link', { name: (n) => n !== 'Toggle navigation' }); - expect(links).toHaveLength(4); + expect(links).toHaveLength(5); }); it('includes button', () => { diff --git a/src/components/navbar/index.tsx b/src/components/navbar/index.tsx index 51347cb76..7b4959ce1 100644 --- a/src/components/navbar/index.tsx +++ b/src/components/navbar/index.tsx @@ -107,6 +107,18 @@ const NavbarNav: React.ComponentType = (props: NavProps) => { )} + {enabledModes.has(Mode.SpellChecker) && ( + + + pathname === '/spellchecker' || (pathname === '/' && defaultMode === Mode.SpellChecker) + } + to={'/spellchecker'} + > + {t('Spell_Check')} + + + )} ); }; diff --git a/src/components/spellchecker/SpellChecker.tsx b/src/components/spellchecker/SpellChecker.tsx new file mode 100644 index 000000000..039d3d8b2 --- /dev/null +++ b/src/components/spellchecker/SpellChecker.tsx @@ -0,0 +1,346 @@ +import './spellchecker.css'; +import * as React from 'react'; +import axios, { CancelTokenSource } from 'axios'; +import Button from 'react-bootstrap/Button'; +import Col from 'react-bootstrap/Col'; +import Form from 'react-bootstrap/Form'; +import classNames from 'classnames'; +import { useHistory } from 'react-router-dom'; +import { useMatomo } from '@datapunt/matomo-tracker-react'; + +import { APyContext } from '../../context'; +import { toAlpha3Code } from '../../util/languages'; + +import { MaxURLLength, buildNewSearch, getUrlParam } from '../../util/url'; +import ErrorAlert from '../ErrorAlert'; +import useLocalStorage from '../../util/useLocalStorage'; +import { useLocalization } from '../../util/localization'; + +interface Suggestion { + token: string; + known: boolean; + sugg: Array; +} + +// eslint-disable-next-line +const Spellers: Readonly> = (window as any).SPELLERS; + +const langUrlParam = 'lang'; +const textUrlParam = 'q'; + +const isKeyUpEvent = (event: React.SyntheticEvent): event is React.KeyboardEvent => event.type === 'keyup'; + +const SpellCheckForm = ({ + setLoading, + setError, +}: { + setLoading: React.Dispatch>; + setError: React.Dispatch>; +}): React.ReactElement => { + const history = useHistory(); + const { t, tLang } = useLocalization(); + const { trackEvent } = useMatomo(); + const apyFetch = React.useContext(APyContext); + const [suggestions, setSuggestions] = React.useState([]); + const [selectedWord, setSelectedWord] = React.useState(null); + const [suggestionPosition, setSuggestionPosition] = React.useState<{ top: number; left: number } | null>(null); + + const initialRender = React.useRef(true); + const spellcheckRef = React.useRef(null); + const spellcheckResult = React.useRef(null); + const spellCheckTimer = React.useRef(null); + + const instantSpellCheck = true; + const instantSpellCheckDelay = 3000; + + const [lang, setLang] = useLocalStorage('spellerLang', Object.keys(Spellers)[0], { + overrideValue: toAlpha3Code(getUrlParam(history.location.search, langUrlParam)), + validateValue: (l) => l in Spellers, + }); + + const [text, setText] = useLocalStorage('spellerText', '', { + overrideValue: getUrlParam(history.location.search, textUrlParam), + }); + + React.useEffect(() => { + let search = buildNewSearch({ [langUrlParam]: lang, [textUrlParam]: text }); + if (search.length > MaxURLLength) { + search = buildNewSearch({ [langUrlParam]: lang }); + } + history.replace({ search }); + }, [history, lang, text]); + + const handleInput = (e: React.FormEvent) => { + const plainText = e.currentTarget.innerText; + setText(plainText.replace(/<\/?[^>]+(>|$)/g, '')); // Strip away any HTML tags + }; + + const handleSubmit = () => { + if (text.trim().length === 0) { + return; + } + + spellcheckResult.current?.cancel(); + spellcheckResult.current = null; + + void (async () => { + try { + trackEvent({ category: 'spellchecker', action: 'spellcheck', name: lang, value: text.length }); + const [ref, request] = apyFetch('spellCheck', { lang, q: text }); + spellcheckResult.current = ref; + const data = (await request).data as Array; + setSuggestions(data); + renderHighlightedText(text, data); + setError(null); + setSelectedWord(null); + setSuggestionPosition(null); + spellcheckResult.current = null; + setLoading(false); + } catch (error) { + if (!axios.isCancel(error)) { + setSuggestions([]); + setSelectedWord(null); + setSuggestionPosition(null); + setError(error as Error); + setLoading(false); + } + } + })(); + }; + + const handleInstantSpellCheck = ( + event: React.KeyboardEvent | React.ClipboardEvent, + ) => { + if (isKeyUpEvent(event) && (event.code === 'Space' || event.code === 'Enter')) { + return; + } + + if (spellCheckTimer.current && instantSpellCheck) { + clearTimeout(spellCheckTimer.current); + } + spellCheckTimer.current = window.setTimeout(() => { + if (spellCheckTimer) { + handleSubmit(); + } + }, instantSpellCheckDelay); + }; + + const handleWordClick = React.useCallback((word: string, event: MouseEvent | TouchEvent) => { + setSelectedWord(word); + const rect = (event.currentTarget as Element).getBoundingClientRect(); + + if ('touches' in event) { + // Get the first touch point + const touch = event.touches[0]; + setSuggestionPosition({ + top: rect.bottom + window.scrollY + 3, + left: touch.clientX + window.scrollX - 2, + }); + } else { + setSuggestionPosition({ + top: rect.bottom + window.scrollY + 3, + left: rect.left + window.scrollX - 2, + }); + } + }, []); + + function saveCaretPosition(editableDiv: HTMLElement): { start: number; end: number } | null { + if (window.getSelection) { + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(editableDiv); + preCaretRange.setEnd(range.startContainer, range.startOffset); + const start = preCaretRange.toString().length; + const end = start + range.toString().length; + return { start, end }; + } + } + return null; + } + + function restoreCaretPosition(editableDiv: HTMLElement, savedSel: { start: number; end: number } | null) { + if (savedSel) { + const charIndex = { count: 0 }; + const range = document.createRange(); + range.setStart(editableDiv, 0); + range.collapse(true); + + const nodeStack: (ChildNode | HTMLElement)[] = [editableDiv]; + let node: ChildNode | null = null; + let foundStart = false; + let stop = false; + + while (!stop && (node = nodeStack.pop() || null)) { + if (node.nodeType === 3) { + // Text node + const textContent = node.textContent || ''; + const nextCharIndex = charIndex.count + textContent.length; + + if (!foundStart && savedSel.start >= charIndex.count && savedSel.start <= nextCharIndex) { + range.setStart(node, savedSel.start - charIndex.count); + foundStart = true; + } + + if (foundStart && savedSel.end >= charIndex.count && savedSel.end <= nextCharIndex) { + range.setEnd(node, savedSel.end - charIndex.count); + stop = true; + } + + charIndex.count = nextCharIndex; + } else { + const elementNode = node as HTMLElement; + let i = elementNode.childNodes.length; + + while (i--) { + nodeStack.push(elementNode.childNodes[i]); + } + } + } + + const sel = window.getSelection(); + if (sel) { + sel.removeAllRanges(); + sel.addRange(range); + } + } + } + + if (initialRender.current && spellcheckRef.current) { + spellcheckRef.current.textContent = text; + initialRender.current = false; + } + + const renderHighlightedText = (text: string, suggestions: Suggestion[]) => { + if (text.trim().length === 0) { + return; + } + + const contentElement = spellcheckRef.current; + if (contentElement instanceof HTMLElement) { + const savedSelection = saveCaretPosition(contentElement); + + const parts = text + .split(/(\s+)/) + .map((word, index) => { + const suggestion = suggestions.find((s) => s.token === word && !s.known); + if (suggestion) { + return `${word}`; + } else { + return `${word}`; + } + }) + .join(''); + + contentElement.innerHTML = parts; + const misspelledElements = contentElement.querySelectorAll('.misspelled'); + misspelledElements.forEach((element) => { + const word = element.textContent || ''; + const eventHandler = (e: Event) => handleWordClick(word, e as MouseEvent | TouchEvent); + element.addEventListener('click', eventHandler); + element.addEventListener('touchstart', eventHandler); + }); + restoreCaretPosition(contentElement, savedSelection); + } + }; + + const applySuggestion = (suggestion: string) => { + if (!selectedWord) return; + + const escapedSelectedWord: string = selectedWord.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + const regex = new RegExp(`(^|\\s)${escapedSelectedWord}(?=\\s|$|[.,!?;:])`, 'gu'); + const updatedText = text.replace(regex, (match, p1: string) => `${p1}${suggestion}`); + + setText(updatedText); + setSelectedWord(null); + setSuggestionPosition(null); + renderHighlightedText(updatedText, suggestions); + }; + + return ( +
event.preventDefault()}> + + {t('Language')} + + setLang(value)} required value={lang}> + {Object.keys(Spellers) + .map((code) => [code, tLang(code)]) + .sort(([, a], [, b]) => a.toLowerCase().localeCompare(b.toLowerCase())) + .map(([code, name]) => ( + + ))} + + + + + {t('Input_Text')} + +
) => { + if (event.code === 'Enter' && !event.shiftKey) { + event.preventDefault(); + handleSubmit(); + } + }} + onKeyUp={handleInstantSpellCheck} + ref={spellcheckRef} + role="textbox" + tabIndex={0} + /> + + + + + + + + {selectedWord && suggestionPosition && suggestions.some((s) => s.token === selectedWord && s.sugg.length > 0) && ( +
+ {suggestions + .find((s) => s.token === selectedWord) + ?.sugg?.map((suggestion, index) => ( +
applySuggestion(suggestion)} + onKeyDown={(event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + applySuggestion(suggestion); + } + }} + role="presentation" + > + {suggestion} +
+ ))} +
+ )} + + ); +}; + +const SpellChecker = (): React.ReactElement => { + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(false); + + return ( + <> + +
{error && }
+ + ); +}; + +export default SpellChecker; diff --git a/src/components/spellchecker/__tests__/SpellChecker.test.tsx b/src/components/spellchecker/__tests__/SpellChecker.test.tsx new file mode 100644 index 000000000..9944b4445 --- /dev/null +++ b/src/components/spellchecker/__tests__/SpellChecker.test.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { MemoryHistory, MemoryHistoryBuildOptions, createMemoryHistory } from 'history'; +import { render, screen } from '@testing-library/react'; +import { Router } from 'react-router-dom'; +import mockAxios from 'jest-mock-axios'; +import userEvent from '@testing-library/user-event'; + +import SpellChecker from '../SpellChecker'; + +const input = 'hello'; + +const renderSpellChecker = (options?: MemoryHistoryBuildOptions): MemoryHistory => { + const history = createMemoryHistory(options); + + render( + + + , + ); + + return history; +}; + +const type = (input: string): HTMLDivElement => { + const divbox = screen.getByRole('textbox'); + divbox.innerText = input; + return divbox as HTMLDivElement; +}; + +const submit = () => userEvent.click(screen.getByRole('button')); + +it('allows selecting a language', () => { + renderSpellChecker(); + + const selector = screen.getByRole('combobox'); + userEvent.selectOptions(selector, screen.getByRole('option', { name: 'қазақша' })); + + expect((selector as HTMLSelectElement).value).toBe('kaz'); +}); + +it('allows typing an input', () => { + renderSpellChecker(); + + const textbox = type(input); + expect(textbox.innerText).toBe(input); +}); + +describe('URL state management', () => { + it('persists language and input', () => { + const history = renderSpellChecker({ initialEntries: [`/?q=${input}`] }); + expect(history.location.search).toBe(`?lang=kaz&q=${input}`); + }); + + it('discards invalid language', () => { + renderSpellChecker({ initialEntries: [`/?lang=kaza`] }); + + const selector = screen.getByRole('combobox'); + expect((selector as HTMLSelectElement).value).toBe('kaz'); + }); + + it('discards long input', () => { + const longInput = 'foobar'.repeat(500); + const history = renderSpellChecker({ initialEntries: [`/?lang=kaz&q=${longInput}`] }); + + expect(history.location.search).toBe(`?lang=kaz`); + }); +}); + +describe('analysis', () => { + it('no-ops an empty input', () => { + renderSpellChecker(); + submit(); + expect(mockAxios.post).not.toBeCalled(); + }); +}); diff --git a/src/components/spellchecker/spellchecker.css b/src/components/spellchecker/spellchecker.css new file mode 100644 index 000000000..1ffb85ca7 --- /dev/null +++ b/src/components/spellchecker/spellchecker.css @@ -0,0 +1,36 @@ +.content-editable { + border: 1px solid #ccc; + padding: 10px; + min-height: 150px; + white-space: pre-wrap; + word-wrap: break-word; +} + +.misspelled { + text-decoration: underline wavy red; + cursor: pointer; +} + +.suggestions { + text-align: center; + position: absolute; + background: white; + border: 1px solid #ccc; + z-index: 1000; + max-height: 150px; + overflow-y: auto; + white-space: nowrap; + width: auto; + padding: 5px; + border-radius: 6px; + box-shadow: 0 0 10px rgb(0 0 0 / 10%); +} + +.suggestions div { + padding: 5px; + cursor: pointer; +} + +.suggestions div:hover { + background-color: #f0f0f0; +} diff --git a/src/strings/eng.json b/src/strings/eng.json index cc7d6a706..ba0128327 100644 --- a/src/strings/eng.json +++ b/src/strings/eng.json @@ -64,5 +64,7 @@ "Downloads_Para": "Current versions of the Apertium toolbox as well as of language-pair data are available from the GitHub page. Installation instructions for Apertium on all major platforms are provided on the Wiki's Installation page.", "Contact_Para": "

IRC channel

The quickest way to contact us is by joining our IRC channel, #apertium at irc.oftc.net, where users and developers of Apertium meet. You don't need an IRC client; you can use OFTC webchat.

Mailing list

Also, subscribe to the apertium-stuff mailing list, where you can post longer proposals or issues, as well as follow general Apertium discussions.

Contact

Feel free to contact us via the apertium-contact mailing list if you find a mistake, there's a project you would like to see us work on, or you would like to help out.

", "Install_Apertium": "Install Apertium", - "Install_Apertium_Para": "Are you experiencing slow responses? Our servers might be overloaded. Learn how to install Apertium locally." + "Install_Apertium_Para": "Are you experiencing slow responses? Our servers might be overloaded. Learn how to install Apertium locally.", + "Spell_Check" : "Spell Checker", + "Spell_Checking_Help" : "Enter text for spell checking." } diff --git a/src/testSetup.ts b/src/testSetup.ts index 19c637dcd..673b50292 100644 --- a/src/testSetup.ts +++ b/src/testSetup.ts @@ -32,6 +32,9 @@ defaultStrings['Maintainer'] = '{{maintainer}}-Default'; // eslint-disable-next-line (window as any).GENERATORS = { eng: 'eng-gener', spa: 'spa-gener' }; +// eslint-disable-next-line +(window as any).SPELLERS = { kaz: 'kaz-spell', hin: 'hin-spell' }; + process.on('unhandledRejection', (err) => { // eslint-disable-next-line jest/no-jasmine-globals fail(err); diff --git a/src/types.ts b/src/types.ts index b6caa1c65..f15b2c8b4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export enum Mode { Analysis = 'analysis', Generation = 'generation', Sandbox = 'sandbox', + SpellChecker = 'spellchecker', } export type Config = {