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 (
-
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
+}