From c6fabb73aefd6243969798c894612931186b6298 Mon Sep 17 00:00:00 2001 From: Yurii Moroz Date: Sun, 10 May 2020 14:53:55 +0300 Subject: [PATCH] Implement synonyms search and inserting --- package-lock.json | 44 +++++++++++++ package.json | 5 +- .../components/file-zone/FileZone.js | 2 +- src/text-editor/plugins/index.js | 4 +- src/text-editor/plugins/synonyms/Synonyms.css | 37 +++++++++++ src/text-editor/plugins/synonyms/Synonyms.js | 63 +++++++++++++++++++ src/text-editor/plugins/synonyms/hooks.js | 35 +++++++++++ src/text-editor/plugins/synonyms/index.js | 5 ++ .../plugins/synonyms/synonyms-api.js | 13 ++++ src/text-editor/shared/selection-helpers.js | 18 ++++++ 10 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 src/text-editor/plugins/synonyms/Synonyms.css create mode 100644 src/text-editor/plugins/synonyms/Synonyms.js create mode 100644 src/text-editor/plugins/synonyms/hooks.js create mode 100644 src/text-editor/plugins/synonyms/index.js create mode 100644 src/text-editor/plugins/synonyms/synonyms-api.js create mode 100644 src/text-editor/shared/selection-helpers.js diff --git a/package-lock.json b/package-lock.json index ca11b0e..edee489 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,11 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/debounce-promise": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/debounce-promise/-/debounce-promise-3.1.1.tgz", + "integrity": "sha512-eOSiMKpPYI5Cx5tmx4xQ1e0bLz9n56NVv4ao0YQcYtIJYZjdoCDFvZwIQgXDiFHU5SFtKtN2vcqx5ZIXvj1Ccg==" + }, "abab": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz", @@ -348,6 +353,30 @@ "postcss-value-parser": "^3.2.3" } }, + "awesome-debounce-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/awesome-debounce-promise/-/awesome-debounce-promise-2.1.0.tgz", + "integrity": "sha512-0Dv4j2wKk5BrNZh4jgV2HUdznaeVgEK/WTvcHhZWUElhmQ1RR+iURRoLEwICFyR0S/5VtxfcvY6gR+qSe95jNg==", + "requires": { + "@types/debounce-promise": "^3.1.1", + "awesome-imperative-promise": "^1.0.1", + "awesome-only-resolves-last-promise": "^1.0.3", + "debounce-promise": "^3.1.0" + } + }, + "awesome-imperative-promise": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/awesome-imperative-promise/-/awesome-imperative-promise-1.0.1.tgz", + "integrity": "sha512-EmPr3FqbQGqlNh+WxMNcF9pO9uDQJnOC4/3rLBQNH9m4E9qI+8lbfHCmHpVAsmGqPJPKhCjJLHUQzQW/RBHRdQ==" + }, + "awesome-only-resolves-last-promise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/awesome-only-resolves-last-promise/-/awesome-only-resolves-last-promise-1.0.3.tgz", + "integrity": "sha512-7q4WPsYiD8Omvi/yHL314DkvsD/lM//Z2/KcU1vWk0xJotiV0GMJTgHTpWl3n90HJqpXKg7qX+VVNs5YbQyPRQ==", + "requires": { + "awesome-imperative-promise": "^1.0.1" + } + }, "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -2694,6 +2723,11 @@ "assert-plus": "^1.0.0" } }, + "debounce-promise": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/debounce-promise/-/debounce-promise-3.1.2.tgz", + "integrity": "sha512-rZHcgBkbYavBeD9ej6sP56XfG53d51CD4dnaw989YX/nZ/ZJfgRx/9ePKmTNiUiyQvh4mtrMoS3OAWW+yoYtpg==" + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -8498,6 +8532,11 @@ "prop-types": "^15.6.2" } }, + "react-async-hook": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/react-async-hook/-/react-async-hook-3.6.1.tgz", + "integrity": "sha512-YWBB2feVQF79t5u2raMPHlZ8975Jds+guCvkWVC4kRLDlSCouLsYpQm4DGSqPeHvoHYVVcDfqNayLZAXQmnxnw==" + }, "react-dev-utils": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-5.0.3.tgz", @@ -10584,6 +10623,11 @@ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" }, + "use-constant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/use-constant/-/use-constant-1.0.0.tgz", + "integrity": "sha512-HmVrMl3+1tEr64ace4UtP5WTdnLyrvYKwF54JVf7B7lSB76JSERDvvgWkaaxlOM3S0dSl1U3WH1l9PupNnzsvQ==" + }, "util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", diff --git a/package.json b/package.json index 658d27d..13e5fc7 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,16 @@ "version": "0.1.0", "private": true, "dependencies": { + "awesome-debounce-promise": "^2.1.0", "classnames": "^2.2.6", "prop-types": "^15.7.2", "ramda": "^0.27.0", "react": "^16.2.0", + "react-async-hook": "^3.6.1", "react-dom": "^16.2.0", "react-icons": "^3.10.0", - "react-scripts": "1.1.1" + "react-scripts": "1.1.1", + "use-constant": "^1.0.0" }, "scripts": { "start": "react-scripts start", diff --git a/src/text-editor/components/file-zone/FileZone.js b/src/text-editor/components/file-zone/FileZone.js index 484e228..f703a25 100644 --- a/src/text-editor/components/file-zone/FileZone.js +++ b/src/text-editor/components/file-zone/FileZone.js @@ -4,7 +4,7 @@ import './FileZone.css'; function FileZone(props) { return (
-
+
{props.children}
diff --git a/src/text-editor/plugins/index.js b/src/text-editor/plugins/index.js index 063f07d..b9b1769 100644 --- a/src/text-editor/plugins/index.js +++ b/src/text-editor/plugins/index.js @@ -1,7 +1,9 @@ import { SIMPLE_ACTION_PLUGIN_CONFIG } from './simple-action'; +import { SYNONYMS_ACTION_PLUGIN_CONFIG } from './synonyms'; const PLUGINS = [ - ...SIMPLE_ACTION_PLUGIN_CONFIG + ...SIMPLE_ACTION_PLUGIN_CONFIG, + ...SYNONYMS_ACTION_PLUGIN_CONFIG ]; export default PLUGINS; diff --git a/src/text-editor/plugins/synonyms/Synonyms.css b/src/text-editor/plugins/synonyms/Synonyms.css new file mode 100644 index 0000000..7e0727f --- /dev/null +++ b/src/text-editor/plugins/synonyms/Synonyms.css @@ -0,0 +1,37 @@ +.synonyms { + position: absolute; + background: #ffffff; + border: 1px solid #bebebe; + box-shadow: 1px 1px 1px #bebebe; +} + +.synonyms__title { + background-color: #e9e9e9; + border-bottom: 1px solid #ccc; + font-size: 14px; + padding: 4px; +} + +.synonyms-list { + list-style: none; + padding: 0; + margin: 0; + min-width: 150px; + max-height: 250px; + overflow-y: auto; +} + +.synonyms-list__button { + background: none; + border: none; + width: 100%; + padding: 5px 15px 5px 15px; + font-size: 14px; + cursor: pointer; + text-align: left; + border-bottom: 1px solid #eee; +} + +.synonyms-list__button:hover { + background: #f1f0f0; +} diff --git a/src/text-editor/plugins/synonyms/Synonyms.js b/src/text-editor/plugins/synonyms/Synonyms.js new file mode 100644 index 0000000..728b3c7 --- /dev/null +++ b/src/text-editor/plugins/synonyms/Synonyms.js @@ -0,0 +1,63 @@ +import React, { useEffect, useLayoutEffect, useRef } from 'react'; +import useConstant from 'use-constant'; +import { useSearchSynonyms } from './hooks'; +import './Synonyms.css'; +import { replaceSelectedText } from '../../shared/selection-helpers'; + +function Synonyms() { + const {synonyms, setText} = useSearchSynonyms(); + const listRefContainer = useRef(null); + + const selectionChangeListener = useConstant(() => () => { + const selection = document.getSelection(); + if (selection) { + setText(selection.toString()); + } + }); + + useEffect( + () => { + document.addEventListener('selectionchange', selectionChangeListener); + return () => document.removeEventListener('selectionchange', selectionChangeListener); + }, + [] + ); + + useLayoutEffect( + () => { + const selection = document.getSelection(); + if (selection && selection.rangeCount > 0 && listRefContainer.current) { + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + listRefContainer.current.style.top = `${rect.top + rect.height + 2}px`; + listRefContainer.current.style.left = `${rect.left - 4}px`; + } + }, + [synonyms.result] + ); + + if (synonyms.result && synonyms.result.length) { + return ( +
+
Select word to replace:
+ +
    + {synonyms.result.map(({word}) => ( +
  • + +
  • + ))} +
+
+ ); + } + + return null; +} + +export default Synonyms; + diff --git a/src/text-editor/plugins/synonyms/hooks.js b/src/text-editor/plugins/synonyms/hooks.js new file mode 100644 index 0000000..4f98d25 --- /dev/null +++ b/src/text-editor/plugins/synonyms/hooks.js @@ -0,0 +1,35 @@ +import { useState } from 'react'; +import useConstant from 'use-constant'; +import AwesomeDebouncePromise from 'awesome-debounce-promise'; +import SynonymsApi from './synonyms-api'; +import { useAsync } from 'react-async-hook'; + +const useSearchSynonyms = () => { + const [text, setText] = useState(''); + + const debouncedLoadSynonyms = useConstant(() => AwesomeDebouncePromise(SynonymsApi.loadSynonyms, 300)); + + const synonyms = useAsync( + async text => { + if (text.trim().length === 0) { + return []; + } + return debouncedLoadSynonyms(text.trim()) + .catch(error => { + console.error(`Error occurred while fetching synonyms: ${error.message}`); + return []; + }); + }, + [text] + ); + + return { + text, + synonyms, + setText + }; +}; + +export { + useSearchSynonyms +}; diff --git a/src/text-editor/plugins/synonyms/index.js b/src/text-editor/plugins/synonyms/index.js new file mode 100644 index 0000000..2d42952 --- /dev/null +++ b/src/text-editor/plugins/synonyms/index.js @@ -0,0 +1,5 @@ +import Synonyms from './Synonyms'; + +export const SYNONYMS_ACTION_PLUGIN_CONFIG = [{ + component: Synonyms +}]; diff --git a/src/text-editor/plugins/synonyms/synonyms-api.js b/src/text-editor/plugins/synonyms/synonyms-api.js new file mode 100644 index 0000000..d68625f --- /dev/null +++ b/src/text-editor/plugins/synonyms/synonyms-api.js @@ -0,0 +1,13 @@ +const SYNONYMS_API_ROOT = 'https://api.datamuse.com/words'; + +const loadSynonyms = async word => { + const response = await fetch(`${SYNONYMS_API_ROOT}?rel_syn=${word}`); + if (!response.ok || response.status !== 200) { + throw new Error('Failed to load synonyms'); + } + return response.json(); +}; + +export default { + loadSynonyms +}; diff --git a/src/text-editor/shared/selection-helpers.js b/src/text-editor/shared/selection-helpers.js new file mode 100644 index 0000000..4ebf7b2 --- /dev/null +++ b/src/text-editor/shared/selection-helpers.js @@ -0,0 +1,18 @@ +const replaceSelectedText = text => { + const selection = document.getSelection(); + if (selection.rangeCount) { + // keep accidentally selected space after the word + if (/.+?\s+$/.test(selection.toString())) { + selection.extend(selection.focusNode, selection.focusOffset - 1); + } + + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(document.createTextNode(text)); + selection.collapseToEnd(); + } +} + +export { + replaceSelectedText +}