├── public ├── Fluentcards128.png ├── Fluentcards16.png ├── Fluentcards256.png ├── Fluentcards48.png └── manifest.json ├── src ├── content │ ├── services │ │ ├── detect-language.js │ │ ├── fetch.js │ │ ├── lookup.js │ │ ├── export.js │ │ ├── speech.js │ │ ├── lookups-store.js │ │ ├── text-utils.js │ │ ├── words-api.js │ │ └── yandex-dictionary.js │ ├── components │ │ ├── Main │ │ │ ├── Main.css │ │ │ └── Main.jsx │ │ ├── SpeakButton │ │ │ ├── SpeakButton.css │ │ │ └── SpeakButton.jsx │ │ ├── SaveButton │ │ │ ├── SaveButton.css │ │ │ └── SaveButton.jsx │ │ ├── Card │ │ │ ├── Card.css │ │ │ └── Card.jsx │ │ ├── Def │ │ │ ├── Def.css │ │ │ └── Def.jsx │ │ └── Popup │ │ │ └── Popup.jsx │ └── index.js ├── popup │ ├── index.js │ ├── components │ │ ├── OptionsButton │ │ │ ├── OptionsButton.css │ │ │ └── OptionsButton.jsx │ │ ├── ExportButton │ │ │ ├── ExportButton.css │ │ │ └── ExportButton.jsx │ │ ├── Logo │ │ │ ├── Logo.css │ │ │ └── Logo.jsx │ │ ├── Root │ │ │ ├── Root.css │ │ │ └── Root.jsx │ │ └── DomainToggle │ │ │ ├── DomainToggle.css │ │ │ └── DomainToggle.jsx │ └── index.html ├── common │ ├── config.js │ └── services │ │ ├── storage.js │ │ └── user-options.js ├── options │ ├── index.js │ └── index.html └── background │ └── index.js ├── bin └── static-files.sh ├── README.md ├── .gitignore ├── package.json └── webpack.config.js /public/Fluentcards128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katspaugh/fluentcards-extension/HEAD/public/Fluentcards128.png -------------------------------------------------------------------------------- /public/Fluentcards16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katspaugh/fluentcards-extension/HEAD/public/Fluentcards16.png -------------------------------------------------------------------------------- /public/Fluentcards256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katspaugh/fluentcards-extension/HEAD/public/Fluentcards256.png -------------------------------------------------------------------------------- /public/Fluentcards48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katspaugh/fluentcards-extension/HEAD/public/Fluentcards48.png -------------------------------------------------------------------------------- /src/content/services/detect-language.js: -------------------------------------------------------------------------------- 1 | export default function detectLanguage() { 2 | return document.documentElement.lang; 3 | }; 4 | -------------------------------------------------------------------------------- /bin/static-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cp public/* dist/ 4 | cp src/popup/index.html dist/popup.html 5 | cp src/options/index.html dist/options.html 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fluentcards Chrome Extension 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/popup/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Root from './components/Root/Root.jsx'; 4 | 5 | 6 | ReactDOM.render( 7 | React.createElement(Root), 8 | document.querySelector('#root') 9 | ); 10 | -------------------------------------------------------------------------------- /src/popup/components/OptionsButton/OptionsButton.css: -------------------------------------------------------------------------------- 1 | .optionsButton { 2 | position: absolute; 3 | right: 20px; 4 | top: 20px; 5 | width: 20px; 6 | height: 20px; 7 | cursor: pointer; 8 | } 9 | 10 | .optionsButton:hover path { 11 | fill: #000; 12 | } 13 | -------------------------------------------------------------------------------- /src/common/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | urls: { 3 | detectApi: 'https://translate.yandex.net/api/v1.5/tr.json/detect?', 4 | dictionaryApi: 'https://dictionary.yandex.net/api/v1/dicservice.json/lookup?&flags=4&', 5 | wordsApi: 'https://dphk13sebjka5.cloudfront.net/' 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /src/content/services/fetch.js: -------------------------------------------------------------------------------- 1 | export default function bFetch(api, params) { 2 | return new Promise((resolve, reject) => { 3 | chrome.runtime.sendMessage( 4 | { api, params }, 5 | 6 | (data) => { 7 | data instanceof Error ? reject(data) : resolve(data); 8 | } 9 | ); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/popup/components/ExportButton/ExportButton.css: -------------------------------------------------------------------------------- 1 | .button { 2 | font-size: 18px; 3 | line-height: 1.2; 4 | padding: 0.7em 2em; 5 | border-radius: 4px; 6 | background-color: #333; 7 | color: #fff; 8 | border: none; 9 | transition: background-color 100ms ease-in; 10 | } 11 | 12 | .button:hover { 13 | background-color: #000; 14 | } 15 | -------------------------------------------------------------------------------- /src/content/components/Main/Main.css: -------------------------------------------------------------------------------- 1 | @keyframes expand { from { width: 0; } to { width: 100%; } } 2 | 3 | .main { 4 | position: relative; 5 | } 6 | 7 | .main:after { 8 | content: ''; 9 | position: absolute; 10 | left: 0; 11 | top: 0; 12 | z-index: 1; 13 | border-top: 4px solid #FBEA31; 14 | animation: expand 600ms ease-in infinite; 15 | } 16 | -------------------------------------------------------------------------------- /src/popup/components/Logo/Logo.css: -------------------------------------------------------------------------------- 1 | .logo:hover, 2 | .logo:active, 3 | .logo:focus, 4 | .logo:visited, 5 | .logo { 6 | font-size: 20px; 7 | color: #333; 8 | text-decoration: none; 9 | outline: none; 10 | } 11 | 12 | .logo:hover { 13 | color: #000; 14 | } 15 | 16 | .logo svg { 17 | height: 28px; 18 | } 19 | 20 | .logo:hover g { 21 | fill: #000; 22 | } 23 | -------------------------------------------------------------------------------- /src/popup/components/ExportButton/ExportButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './ExportButton.css'; 3 | 4 | 5 | function exportContent() { 6 | chrome.runtime.sendMessage({ event: 'exportCards' }); 7 | } 8 | 9 | export default function render() { 10 | return ( 11 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | yarn.lock 25 | dist/ 26 | dist.zip -------------------------------------------------------------------------------- /src/common/services/storage.js: -------------------------------------------------------------------------------- 1 | const storage = chrome.storage.local; 2 | 3 | export default class Storage { 4 | static set(key, data) { 5 | return new Promise(resolve => storage.set({ [key]: data }, resolve)); 6 | } 7 | 8 | static get(key) { 9 | return new Promise(resolve => storage.get([ key ], (data) => resolve(data[key]))); 10 | } 11 | 12 | static getAll() { 13 | return new Promise(resolve => storage.get(undefined, (data) => resolve(data))); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/popup/components/Root/Root.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: #FBEA31; 3 | padding: 20px; 4 | position: relative; 5 | } 6 | 7 | .heading { 8 | font-size: 20px; 9 | margin: 0; 10 | text-transform: lowercase; 11 | } 12 | 13 | .main { 14 | margin-top: 25px; 15 | } 16 | 17 | .desc { 18 | margin: 20px 0; 19 | } 20 | 21 | .controls { 22 | display: flex; 23 | justify-content: center; 24 | } 25 | 26 | .link { 27 | margin: 15px 0 0; 28 | text-align: center; 29 | } 30 | 31 | .link a { 32 | color: inherit; 33 | } 34 | -------------------------------------------------------------------------------- /src/content/services/lookup.js: -------------------------------------------------------------------------------- 1 | import detectLanguage from './detect-language'; 2 | import yandexDefine from './yandex-dictionary'; 3 | import wordsApiDefine from './words-api'; 4 | 5 | function load(word, lang, targetLang) { 6 | return wordsApiDefine(word, lang, targetLang) 7 | .catch(() => yandexDefine(word, lang, targetLang)) 8 | .then(data => ({ lang: lang, data: data })); 9 | } 10 | 11 | export default function lookup(word, targetLang, sourceLang) { 12 | const lang = detectLanguage() || sourceLang || 'en'; 13 | return load(word, lang, targetLang); 14 | } 15 | -------------------------------------------------------------------------------- /src/content/services/export.js: -------------------------------------------------------------------------------- 1 | import lookupsStore from '../services/lookups-store'; 2 | 3 | function insertScript(src) { 4 | const script = document.createElement('script'); 5 | script.setAttribute('src', 'data:text/javascript;charset=utf-8,' + encodeURIComponent(src)); 6 | document.body.appendChild(script); 7 | } 8 | 9 | export function exportCards() { 10 | if (!/fluentcards/.test(window.location.hostname)) return; 11 | 12 | lookupsStore.getAll() 13 | .then((items) => { 14 | insertScript('window.fluentcards = ' + JSON.stringify(items)); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/content/services/speech.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Speak a piece of text 3 | * 4 | * @param {string} text 5 | * @param {string} lang 6 | */ 7 | export default function speak(text, lang) { 8 | if (lang === 'en') lang = 'en-US'; 9 | 10 | speechSynthesis.cancel(); 11 | 12 | let speech = new SpeechSynthesisUtterance(); 13 | 14 | const voice = speechSynthesis.getVoices().filter(voice => { 15 | return !voice.localService && voice.lang.startsWith(lang); 16 | })[0]; 17 | if (voice) speech.voice = voice; 18 | 19 | speech.text = text; 20 | speech.lang = lang; 21 | speech.rate = 1; 22 | speechSynthesis.speak(speech); 23 | } 24 | -------------------------------------------------------------------------------- /src/content/components/SpeakButton/SpeakButton.css: -------------------------------------------------------------------------------- 1 | .button { 2 | cursor: pointer; 3 | width: 16px; 4 | height: 16px; 5 | padding: 4px; 6 | box-sizing: content-box; 7 | margin-right: -4px; 8 | user-select: none; 9 | -webkit-user-select: none; 10 | opacity: 0.7; 11 | } 12 | 13 | .button:hover { 14 | opacity: 1; 15 | } 16 | 17 | .button svg { 18 | display: block; 19 | } 20 | 21 | .button polygon { 22 | fill: #333; 23 | stroke: #333; 24 | stroke-width: 5; 25 | stroke-linejoin: round; 26 | } 27 | 28 | .button path { 29 | fill: none; 30 | stroke: #333; 31 | stroke-width: 5; 32 | stroke-linejoin: round; 33 | } 34 | -------------------------------------------------------------------------------- /src/common/services/user-options.js: -------------------------------------------------------------------------------- 1 | import storage from './storage.js'; 2 | 3 | const storageKey = 'userOptions'; 4 | 5 | const defaultOptions = { 6 | targetLanguage: 'en', 7 | sourceLanguage: '', 8 | ttsEnabled: false 9 | }; 10 | 11 | export default class UserOptions { 12 | static getDefaults() { 13 | return defaultOptions; 14 | } 15 | 16 | static get() { 17 | return storage.get(storageKey).then(options => { 18 | return Object.assign({}, defaultOptions, options ? JSON.parse(options) : {}); 19 | }); 20 | } 21 | 22 | static set(options) { 23 | return storage.set(storageKey, JSON.stringify(options)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/popup/components/Logo/Logo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './Logo.css'; 3 | 4 | export default function render() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | Fluentcards 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/content/components/SaveButton/SaveButton.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 0 10px; 3 | flex-direction: column; 4 | display: flex; 5 | align-items: center; 6 | } 7 | 8 | .container button { 9 | display: block; 10 | box-sizing: border-box; 11 | width: 80%; 12 | max-width: 250px; 13 | margin: auto; 14 | padding: 0.7em; 15 | font-family: Helvetica, Arial, sans-serif; 16 | color: #333; 17 | border: 1px solid #FBEA31; 18 | background: #FBEA31; 19 | font-size: 14px; 20 | font-weight: normal; 21 | border-radius: 4px; 22 | margin: 0; 23 | cursor: pointer; 24 | } 25 | 26 | .container button[disabled] { 27 | background: #fff; 28 | color: graytext; 29 | cursor: default; 30 | } 31 | 32 | .container button:not([disabled]):hover { 33 | border-color: #333; 34 | } 35 | -------------------------------------------------------------------------------- /src/content/services/lookups-store.js: -------------------------------------------------------------------------------- 1 | import storage from '../../common/services/storage'; 2 | 3 | 4 | class LookupsStore { 5 | getAll() { 6 | return storage.getAll().then((data) => { 7 | return Object.keys(data) 8 | .map(Number) 9 | .filter(key => !isNaN(key)) 10 | .sort() 11 | .map((key) => data[key]); 12 | }); 13 | } 14 | 15 | saveOne(word, context, info) { 16 | const item = { 17 | language: info.lang, 18 | selection: word, 19 | context: context, 20 | def: info.data.def.slice(0, 1) 21 | }; 22 | 23 | const isEqual = (it) => it.selection == item.selection && it.context == item.context; 24 | 25 | return this.getAll().then(items => { 26 | if (items.some(isEqual)) return; 27 | 28 | return storage.set(Date.now(), item); 29 | }); 30 | } 31 | } 32 | 33 | export default new LookupsStore(); 34 | -------------------------------------------------------------------------------- /src/content/components/Card/Card.css: -------------------------------------------------------------------------------- 1 | .card { 2 | display: block; 3 | box-shadow: 0 0 10px #aaa; 4 | background: #fff; 5 | padding: 20px; 6 | min-width: 350px; 7 | max-width: 450px; 8 | font-size: 14px; 9 | color: #333; 10 | font-family: Helvetica, Arial, sans-serif; 11 | position: absolute; 12 | z-index: 2; 13 | top: 0; 14 | left: 0; 15 | animation: fade 100ms reverse ease-in; 16 | user-select: none; 17 | -webkit-user-select: none; 18 | } 19 | 20 | .yandex { 21 | text-align: right; 22 | font-size: 10px; 23 | color: #888; 24 | font-weight: 100; 25 | margin-top: 3px; 26 | margin-bottom: -5px; 27 | } 28 | 29 | .yandex a { 30 | color: #888 !important; 31 | font-style: normal !important; 32 | font-size: 10px !important; 33 | font-weight: 100 !important; 34 | line-height: 1.2 !important; 35 | text-decoration: underline !important; 36 | border: none !important; 37 | } 38 | 39 | .yandex a:hover { 40 | color: #777 !important; 41 | text-decoration: none !important; 42 | } 43 | -------------------------------------------------------------------------------- /src/content/services/text-utils.js: -------------------------------------------------------------------------------- 1 | export function getContext(word, text) { 2 | let partialContext = text.replace(/\s+/g, ' '); 3 | let sentence = partialContext; 4 | 5 | partialContext.replace(/.+?[.!?]/g, s => { 6 | if (s.indexOf(word) != -1) { 7 | sentence = s; 8 | } 9 | }); 10 | 11 | return sentence.replace(/\n+/g, ' ').trim(); 12 | } 13 | 14 | export function isValidSelection (selectedText) { 15 | const minWords = 1; 16 | const maxWords = 5; 17 | 18 | if (!selectedText || !selectedText.trim()) return false; 19 | if (/^[0-9,.:;/?!@#$%&*()=_+<>|"'}{\[\]«»‘’“”~`±§ -]+$/.test(selectedText)) return false; 20 | let wordsLen = selectedText.trim().split(' ').length; 21 | return wordsLen >= minWords && wordsLen <= maxWords; 22 | } 23 | 24 | export function splitWords(text) { 25 | return text.split(' '); 26 | } 27 | 28 | export function getArticle(data, lang) { 29 | const articles = { 30 | de: { 31 | pl: 'die', 32 | m: 'der', 33 | f: 'die', 34 | n: 'das' 35 | } 36 | }; 37 | 38 | return articles[lang] ? articles[lang][data.num] || articles[lang][data.gen] : null; 39 | } 40 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Fluentcards Dictionary", 3 | "version": "0.8.4", 4 | "description": "Look up words in a dictionary and save as flashcards.", 5 | "permissions": [ 6 | "tabs", 7 | "contextMenus", 8 | "", 9 | "storage", 10 | "unlimitedStorage" 11 | ], 12 | 13 | "options_ui": { 14 | "page": "options.html", 15 | "chrome_style": true 16 | }, 17 | 18 | "background": { 19 | "scripts": [ 20 | "background.js" 21 | ] 22 | }, 23 | 24 | "browser_action": { 25 | "default_icon": { 26 | "19": "Fluentcards48.png", 27 | "38": "Fluentcards48.png" 28 | }, 29 | "default_title": "Fluentcards", 30 | "default_popup": "popup.html" 31 | }, 32 | 33 | "minimum_chrome_version": "20", 34 | 35 | "content_scripts": [ 36 | { 37 | "matches": [ 38 | "" 39 | ], 40 | "js": [ 41 | "content.js" 42 | ] 43 | } 44 | ], 45 | 46 | "icons": { 47 | "16": "Fluentcards16.png", 48 | "48": "Fluentcards48.png", 49 | "128": "Fluentcards128.png", 50 | "256": "Fluentcards256.png" 51 | }, 52 | 53 | "manifest_version": 2 54 | } 55 | -------------------------------------------------------------------------------- /src/content/services/words-api.js: -------------------------------------------------------------------------------- 1 | import fetchJson from './fetch'; 2 | 3 | /** 4 | * Download a definition of a word 5 | * 6 | * @param {string} word 7 | * @returns {promise} 8 | */ 9 | export default function getDefinition(word, lang, targetLang) { 10 | if (lang !== 'en' || targetLang !== 'en') { 11 | return Promise.reject(new Error('Unsupported language')); 12 | } 13 | 14 | return fetchJson('wordsApi', word) 15 | .then(data => ({ 16 | def: data.results 17 | .reduce((acc, next) => { 18 | const prev = acc[acc.length - 1]; 19 | 20 | if (prev && prev.partOfSpeech === next.partOfSpeech) { 21 | prev.definition.push(next.definition); 22 | } else { 23 | next.definition = [ next.definition ]; 24 | acc.push(next); 25 | } 26 | 27 | return acc; 28 | }, []) 29 | .map(result => ({ 30 | text: data.word, 31 | ts: data.pronunciation ? 32 | data.pronunciation[result.partOfSpeech] || data.pronunciation.all : 33 | '', 34 | tr: result.definition.map(text => ({ text })), 35 | pos: result.partOfSpeech 36 | })) 37 | })); 38 | }; 39 | -------------------------------------------------------------------------------- /src/options/index.js: -------------------------------------------------------------------------------- 1 | import userOptions from '../common/services/user-options'; 2 | 3 | // Save options to the storage 4 | function saveOptions(form) { 5 | const data = { 6 | targetLanguage: form.elements.targetLanguage.value, 7 | sourceLanguage: form.elements.sourceLanguage.value 8 | }; 9 | 10 | userOptions.set(data).then(() => { 11 | // Update status to let user know options were saved. 12 | const status = document.getElementById('status'); 13 | status.textContent = 'Options saved.'; 14 | 15 | setTimeout(() => { 16 | status.textContent = ''; 17 | }, 750); 18 | }); 19 | } 20 | 21 | // Restore the form state using the preferences stored in the storage. 22 | function restoreOptions(form) { 23 | userOptions.get().then(options => { 24 | form.elements.targetLanguage.value = options.targetLanguage; 25 | form.elements.sourceLanguage.value = options.sourceLanguage; 26 | }); 27 | } 28 | 29 | // Init 30 | document.addEventListener('DOMContentLoaded', () => { 31 | let form = document.getElementById('options-form'); 32 | 33 | restoreOptions(form); 34 | 35 | form.addEventListener('submit', (e) => { 36 | e.preventDefault(); 37 | saveOptions(form); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/content/components/Card/Card.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import Def from '../Def/Def.jsx'; 3 | import styles from './Card.css'; 4 | 5 | const maxDefinitions = 2; 6 | 7 | export default class Card extends PureComponent { 8 | render() { 9 | const defData = this.props.data.data.def 10 | .slice(0, maxDefinitions); 11 | 12 | const showPos = defData.some((item, index) => { 13 | const prev = defData[index - 1] 14 | if (!prev) return false; 15 | return item.text === prev.text && item.pos !== prev.pos; 16 | }); 17 | 18 | const items = defData 19 | .map((def, i) => { 20 | return ( 21 | 22 | ); 23 | }); 24 | 25 | return ( 26 |
27 | { items } 28 | 29 | { this.props.children } 30 | 31 |
32 | Powered by 33 | Yandex.Translate 34 | and 35 | WordsAPI 36 | 37 |
38 |
39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/content/components/Def/Def.css: -------------------------------------------------------------------------------- 1 | .def { 2 | margin-bottom: 1.5em; 3 | position: relative; 4 | } 5 | 6 | .speak { 7 | position: absolute; 8 | z-index: 1; 9 | right: 0; 10 | top: 0; 11 | } 12 | 13 | .word { 14 | font-size: 1.5em; 15 | padding-right: 20px; 16 | } 17 | 18 | .extra { 19 | color: #888; 20 | font-weight: 100; 21 | font-size: 0.7em; 22 | color: #aaa; 23 | padding-left: 0.5em; 24 | } 25 | 26 | .gen { 27 | font-style: italic; 28 | } 29 | 30 | .ts { 31 | font-size: 14px; 32 | color: #888; 33 | padding-left: 0.5em; 34 | } 35 | 36 | .tsBlock { 37 | padding-top: 2px; 38 | } 39 | 40 | .tsBlock .ts { 41 | padding-left: 0; 42 | } 43 | 44 | .definitions { 45 | margin: 1em 0 0; 46 | } 47 | 48 | .definitions div { 49 | display: list-item; 50 | list-style-type: disc; 51 | margin-left: 1.3em; 52 | margin-bottom: 0.5em; 53 | } 54 | 55 | .pos { 56 | text-transform: lowercase; 57 | color: #888; 58 | font-weight: 100; 59 | font-size: 0.5em; 60 | margin-left: 0.5em; 61 | vertical-align: super; 62 | } 63 | 64 | .examples { 65 | margin: 1em 0 0; 66 | font-style: italic; 67 | } 68 | 69 | .examples span { 70 | color: #888; 71 | font-weight: 100; 72 | font-style: normal; 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluentcards", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@babel/core": "7.5.5", 7 | "babel-loader": "8.0.6", 8 | "babel-plugin-named-asset-import": "^0.3.3", 9 | "babel-preset-react-app": "^9.0.1", 10 | "css-loader": "2.1.1", 11 | "html-webpack-plugin": "4.0.0-beta.5", 12 | "lodash": "^4.17.15", 13 | "mini-css-extract-plugin": "0.5.0", 14 | "optimize-css-assets-webpack-plugin": "5.0.3", 15 | "react": "^16.9.0", 16 | "react-dom": "^16.9.0", 17 | "style-loader": "^1.0.0", 18 | "textarea-caret": "^3.1.0", 19 | "webpack": "4.39.1", 20 | "webpack-cli": "^3.3.8", 21 | "webpack-dev-server": "3.2.1" 22 | }, 23 | "scripts": { 24 | "start": "webpack --progress --colors --watch", 25 | "build": "NODE_ENV='production' webpack -p; ./bin/static-files.sh" 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.2%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | }, 39 | "babel": { 40 | "presets": [ 41 | "react-app" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/content/components/SpeakButton/SpeakButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import speak from '../../services/speech.js'; 3 | import styles from './SpeakButton.css'; 4 | 5 | 6 | export default class SpeakButton extends PureComponent { 7 | constructor() { 8 | super(); 9 | 10 | this.onClick = (e) => { 11 | e.preventDefault(); 12 | e.stopPropagation(); 13 | speak(this.props.word, this.props.lang); 14 | }; 15 | } 16 | 17 | render() { 18 | return ( 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | mode: process.env.NODE_ENV, 6 | 7 | devtool: process.env.NODE_ENV === 'production' ? false : 'source-map', 8 | cache: true, 9 | 10 | entry: { 11 | content: [ path.resolve(__dirname, 'src/content/index.js') ], 12 | background: [ path.resolve(__dirname, 'src/background/index.js') ], 13 | popup: [ path.resolve(__dirname, 'src/popup/index.js') ], 14 | options: [ path.resolve(__dirname, 'src/options/index.js') ] 15 | }, 16 | 17 | output: { 18 | path: path.resolve(__dirname, 'dist'), 19 | filename: '[name].js' 20 | }, 21 | 22 | plugins: [ 23 | new webpack.DefinePlugin({ 24 | 'process.env': { 25 | NODE_ENV: JSON.stringify('production') 26 | } 27 | }) 28 | ], 29 | 30 | module: { 31 | rules: [ 32 | { 33 | test: /.jsx?$/, 34 | loader: 'babel-loader', 35 | include: [ 36 | path.resolve(__dirname, 'src') 37 | ] 38 | }, 39 | { 40 | test: /\.css$/, 41 | use: [ 42 | 'style-loader', 43 | { loader: 'css-loader', options: { modules: true } } 44 | ] 45 | } 46 | ] 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/content/components/SaveButton/SaveButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import lookupsStore from '../../services/lookups-store'; 3 | import styles from './SaveButton.css'; 4 | 5 | 6 | export default class SaveButton extends PureComponent { 7 | constructor() { 8 | super(); 9 | 10 | this.state = { 11 | saving: false, 12 | saved: false 13 | }; 14 | 15 | this._onClick = () => this.saveWord(); 16 | } 17 | 18 | saveWord() { 19 | this.setState({ saving: true }); 20 | 21 | // Save the lookup in the storage 22 | lookupsStore 23 | .saveOne(this.props.word, this.props.context, this.props.data) 24 | .then(() => { 25 | this.setState({ saving: false, saved: true }); 26 | }) 27 | .catch(() => { 28 | this.setState({ saving: false }); 29 | }); 30 | } 31 | 32 | componentWillMount() { 33 | if (this.props.saveOnMount) { 34 | this.saveWord(); 35 | } 36 | } 37 | 38 | render() { 39 | return ( 40 |
41 | 44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/popup/components/DomainToggle/DomainToggle.css: -------------------------------------------------------------------------------- 1 | .toggle { 2 | padding-right: 2em; 3 | } 4 | 5 | input[type="checkbox"] { 6 | position: absolute; 7 | margin: 8px 0 0 16px; 8 | } 9 | input[type="checkbox"] + label { 10 | position: relative; 11 | padding: 5px 0 0 50px; 12 | } 13 | input[type="checkbox"] + label:before { 14 | content: ""; 15 | position: absolute; 16 | display: block; 17 | left: 0; 18 | top: 0; 19 | width: 40px; /* x*5 */ 20 | height: 24px; /* x*3 */ 21 | border-radius: 16px; /* x*2 */ 22 | background: #fff; 23 | border: 1px solid #d9d9d9; 24 | transition: all 0.3s; 25 | } 26 | input[type="checkbox"] + label:after { 27 | content: ''; 28 | position: absolute; 29 | display: block; 30 | left: 0px; 31 | top: 0px; 32 | width: 24px; /* x*3 */ 33 | height: 24px; /* x*3 */ 34 | border-radius: 16px; /* x*2 */ 35 | background: #fff; 36 | border: 1px solid #d9d9d9; 37 | transition: all 0.3s; 38 | } 39 | input[type="checkbox"] + label:hover:after { 40 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); 41 | } 42 | input[type="checkbox"]:checked + label:after { 43 | margin-left: 16px; 44 | } 45 | input[type="checkbox"]:checked + label:before { 46 | background: #333; 47 | } 48 | 49 | input[type="checkbox"] + label>span:before { 50 | content: 'Off '; 51 | } 52 | 53 | input[type="checkbox"]:checked + label>span:before { 54 | content: 'Enabled '; 55 | } 56 | -------------------------------------------------------------------------------- /src/background/index.js: -------------------------------------------------------------------------------- 1 | import storage from '../common/services/storage'; 2 | import config from '../common/config'; 3 | 4 | // Export words 5 | chrome.runtime.onMessage.addListener(msg => { 6 | switch (msg && msg.event) { 7 | case 'exportCards': 8 | return chrome.tabs.create({ url: 'https://fluentcards.com/vocab' }); 9 | } 10 | }); 11 | 12 | // Saved words counter 13 | function updateCount() { 14 | storage.getAll().then(data => { 15 | const count = Object.keys(data) 16 | .map(Number) 17 | .filter(key => !isNaN(Number(key))) 18 | .length; 19 | 20 | return chrome.browserAction.setBadgeText({ text: String(count) }); 21 | }); 22 | } 23 | updateCount(); 24 | chrome.storage.onChanged.addListener(updateCount); 25 | chrome.browserAction.setBadgeBackgroundColor({ color: '#aaa' }); 26 | 27 | 28 | // Create a context menu item to save the selection 29 | chrome.contextMenus.create({ 30 | title: 'Add to Fluentcards', contexts: [ 'selection' ], 31 | onclick: (info, tab) => { 32 | chrome.tabs.sendMessage(tab.id, { event: 'saveSelection' }); 33 | } 34 | }); 35 | 36 | // Make requests on content script's behalf 37 | chrome.runtime.onMessage.addListener( 38 | (request, sender, sendResponse) => { 39 | if (request.api) { 40 | fetch(config.urls[request.api] + request.params) 41 | .then(response => response.json()) 42 | .then(data => sendResponse(data)) 43 | .catch(error => sendResponse(error)); 44 | return true; 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /src/popup/components/OptionsButton/OptionsButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './OptionsButton.css'; 3 | 4 | 5 | function openOptions() { 6 | chrome.runtime.openOptionsPage(); 7 | } 8 | 9 | export default function render() { 10 | return ( 11 |
12 | 13 | 14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/content/components/Main/Main.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import userOptions from '../../../common/services/user-options' 3 | import lookup from '../../services/lookup.js'; 4 | import Card from '../Card/Card.jsx'; 5 | import SaveButton from '../SaveButton/SaveButton.jsx'; 6 | import styles from './Main.css'; 7 | 8 | const options = userOptions.getDefaults(); 9 | userOptions.get().then(data => Object.assign(options, data)); 10 | 11 | export default class Main extends PureComponent { 12 | constructor() { 13 | super(); 14 | 15 | this.state = { 16 | data: null 17 | }; 18 | 19 | this._onLoad = result => this.onLoad(result); 20 | } 21 | 22 | onLoad(result) { 23 | this.props.onLoad(); 24 | 25 | if (!result) return; 26 | 27 | // Display the definition 28 | this.setState({ data: result }); 29 | } 30 | 31 | componentDidMount() { 32 | lookup(this.props.word, options.targetLanguage, options.sourceLanguage) 33 | .then(data => this._onLoad(data)) 34 | .catch(() => this._onLoad()); 35 | } 36 | 37 | componentWillUnmount() { 38 | this._onLoad = () => null; 39 | } 40 | 41 | render() { 42 | return ( 43 |
44 | { this.state.data ? ( 45 | 46 | 51 | 52 | ) : null } 53 |
54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/content/index.js: -------------------------------------------------------------------------------- 1 | import { debounce } from 'lodash'; 2 | import { isValidSelection } from './services/text-utils.js'; 3 | import { exportCards } from './services/export.js'; 4 | import storage from '../common/services/storage.js'; 5 | import Popup from './components/Popup/Popup.jsx'; 6 | 7 | 8 | function createPopup(selection, shouldSave = false) { 9 | return new Popup(selection, shouldSave); 10 | } 11 | 12 | function initEvents() { 13 | let isDoubleClick = false; 14 | let popup = null; 15 | 16 | const reset = () => { 17 | if (popup) { 18 | popup.remove(); 19 | popup = null; 20 | } 21 | }; 22 | 23 | document.addEventListener('dblclick', () => { 24 | isDoubleClick = true; 25 | }); 26 | 27 | document.addEventListener('selectionchange', debounce(() => { 28 | reset(); 29 | 30 | if (!isDoubleClick) return; 31 | 32 | const selection = window.getSelection(); 33 | if (!isValidSelection(selection.toString())) return; 34 | 35 | popup = createPopup(selection); 36 | isDoubleClick = false; 37 | }, 200)); 38 | 39 | // To avoid showing the definition when the user double-clicks 40 | // to copy the selection 41 | document.addEventListener('keydown', () => { 42 | if (popup && popup.isDismissable) reset(); 43 | }); 44 | 45 | // Save the selection from the context menu 46 | chrome.runtime.onMessage.addListener(msg => { 47 | if (msg && msg.event === 'saveSelection') { 48 | popup = createPopup(window.getSelection(), true); 49 | } 50 | }); 51 | } 52 | 53 | function isDomainEnabled() { 54 | return storage.get(window.location.hostname) 55 | .then(domain => domain == null ? true : domain); 56 | } 57 | 58 | function init() { 59 | exportCards(); 60 | 61 | isDomainEnabled().then(isEnabled => { 62 | if (isEnabled) initEvents(); 63 | }); 64 | } 65 | 66 | init(); 67 | -------------------------------------------------------------------------------- /src/popup/components/Root/Root.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import storage from '../../../common/services/storage'; 3 | import userOptions from '../../../common/services/user-options'; 4 | import Logo from '../Logo/Logo.jsx'; 5 | import OptionsButton from '../OptionsButton/OptionsButton.jsx'; 6 | import DomainToggle from '../DomainToggle/DomainToggle.jsx'; 7 | import ExportButton from '../ExportButton/ExportButton.jsx'; 8 | import styles from './Root.css'; 9 | 10 | const langs = { 11 | es: 'Spanish', 12 | fr: 'French', 13 | de: 'German' 14 | }; 15 | 16 | export default class Root extends PureComponent { 17 | constructor() { 18 | super(); 19 | 20 | this.state = { 21 | hasItems: 0, 22 | userLang: '' 23 | }; 24 | } 25 | 26 | checkItems() { 27 | return storage.getAll().then((data) => { 28 | return Object.keys(data).some(key => !isNaN(Number(key))); 29 | }); 30 | } 31 | 32 | componentDidMount() { 33 | this.checkItems().then(hasItems => { 34 | this.setState({ hasItems }); 35 | }); 36 | 37 | userOptions.get().then(options => { 38 | this.setState({ userLang: options.sourceLanguage }); 39 | }); 40 | } 41 | 42 | render() { 43 | const targetLang = langs[this.state.userLang] || 'a foreign language'; 44 | 45 | return ( 46 |
47 |

48 | 49 | 50 | 51 |

52 | 53 |
54 | 55 | 56 |

57 | Double-click on any word on the page to look it up in the dictionary. 58 |

59 | 60 | { this.state.hasItems ? ( 61 |
62 | 63 |
64 | ) : null } 65 |
66 |
67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/popup/components/DomainToggle/DomainToggle.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import storage from '../../../common/services/storage'; 3 | import styles from './DomainToggle.css'; 4 | 5 | 6 | function getDomain() { 7 | return new Promise((resolve, reject) => { 8 | chrome.tabs.query({ 9 | active: true, 10 | currentWindow: true 11 | }, (tabs) => { 12 | if (!tabs[0]) { 13 | reject('No active tab'); 14 | return; 15 | } 16 | 17 | const url = tabs[0].url; 18 | const link = document.createElement('a'); 19 | link.href = url; 20 | const domain = link.hostname; 21 | 22 | resolve(domain); 23 | }); 24 | }); 25 | } 26 | 27 | function isDomainEnabled(domain) { 28 | return storage.get(domain) 29 | .then(data => (!data ? true : data.isEnabled)); 30 | } 31 | 32 | function toggleSite(domain, isEnabled) { 33 | storage.set(domain, { isEnabled }); 34 | } 35 | 36 | export default class DomainToggle extends PureComponent { 37 | constructor() { 38 | super(); 39 | 40 | this.state = { 41 | domain: '', 42 | isEnabled: true 43 | }; 44 | 45 | this.onChange = e => { 46 | const isEnabled = e.target.checked; 47 | toggleSite(this.state.domain, isEnabled); 48 | this.setState({ isEnabled }); 49 | }; 50 | } 51 | 52 | componentDidMount() { 53 | getDomain() 54 | .then(domain => { 55 | isDomainEnabled(domain).then(isEnabled => { 56 | this.setState({ domain, isEnabled }); 57 | }); 58 | }); 59 | } 60 | 61 | render() { 62 | const domain = this.state.domain.replace(/^www\./, ''); 63 | 64 | return ( 65 |
66 | 70 | 73 |
74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/content/services/yandex-dictionary.js: -------------------------------------------------------------------------------- 1 | import fetchJson from './fetch'; 2 | 3 | const apiKeys = [ 4 | 'ZGljdC4xLjEuMjAxNTA4MTdUMDgxMTAzWi43YWM4YTUzODk0OTFjYTE1LjkxNjQwNjQwNzEyM2Y2MDlmZDBiZjkzYzEyMjE5MGQ1NmFmNjM1OWM=', 5 | 'ZGljdC4xLjEuMjAxNDA4MTBUMTgwODQyWi40YzA1ZmEyMzkyOWQ4OTFiLjA5Y2QzOTUyZDQ4Njk2YzYzOWIxNjRhNzcxZjY5NDU2N2IwNGJkZWY=', 6 | 'ZGljdC4xLjEuMjAxNDExMjJUMTIwMzA2Wi40ZTQ2NzY1ZGQyMDYwMTBhLjNlNGExYjE4MmRmNWQ4OTJmZDc0ZGQzZTQ0ZjM4OWIwZTVhZWVhMjQ=' 7 | ]; 8 | 9 | const endpoint = 'https://dictionary.yandex.net/api/v1/dicservice.json/lookup?&flags=4'; 10 | 11 | // eslint-disable-next-line 12 | const langs = [ 'be-be','be-ru','bg-ru','cs-en','cs-ru','da-en','da-ru','de-de','de-en','de-ru','de-tr','el-en','el-ru','en-cs','en-da','en-de','en-el','en-en','en-es','en-et','en-fi','en-fr','en-it','en-lt','en-lv','en-nl','en-no','en-pt','en-ru','en-sk','en-sv','en-tr','en-uk','es-en','es-es','es-ru','et-en','et-ru','fi-en','fi-ru','fr-en','fr-fr','fr-ru','it-en','it-it','it-ru','lt-en','lt-ru','lv-en','lv-ru','nl-en','nl-ru','no-en','no-ru','pl-ru','pt-en','pt-ru','ru-be','ru-bg','ru-cs','ru-da','ru-de','ru-el','ru-en','ru-es','ru-et','ru-fi','ru-fr','ru-it','ru-lt','ru-lv','ru-nl','ru-no','ru-pl','ru-pt','ru-ru','ru-sk','ru-sv','ru-tr','ru-tt','ru-uk','sk-en','sk-ru','sv-en','sv-ru','tr-de','tr-en','tr-ru','tt-ru','uk-en','uk-ru','uk-uk' ]; 13 | 14 | const defaultLang = 'en'; 15 | 16 | /** 17 | * Download a dictionary definition of a word 18 | * 19 | * @param {string} text 20 | * @param {string} lang 21 | * @param {string} targetLang 22 | * @returns {promise} 23 | */ 24 | export default function yandexDefine(text, lang, targetLang) { 25 | let langPair = `${ lang }-${ targetLang }`; 26 | 27 | if (!langs.includes(langPair)) { 28 | langPair = `${ defaultLang }-${ targetLang }`; 29 | } 30 | 31 | if (!langs.includes(langPair)) { 32 | langPair = `${ defaultLang }-${ defaultLang }`; 33 | } 34 | 35 | if (!langs.includes(langPair)) { 36 | return Promise.reject('Missing language pair'); 37 | } 38 | 39 | const params = [ 40 | 'key=' + atob(apiKeys[~~(Math.random() * apiKeys.length)]), 41 | 'lang=' + langPair, 42 | 'text=' + encodeURIComponent(text) 43 | ].join('&'); 44 | 45 | return fetchJson('dictionaryApi', params) 46 | .then(data => { 47 | if (data && data.def && data.def.length) return data; 48 | 49 | throw new Error('No data'); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /src/content/components/Def/Def.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { getArticle, splitWords } from '../../services/text-utils.js'; 3 | import SpeakButton from '../SpeakButton/SpeakButton.jsx'; 4 | import styles from './Def.css'; 5 | 6 | const maxTrs = 3; 7 | const maxLongTrs = 2; 8 | 9 | export default class Def extends PureComponent { 10 | renderHeading() { 11 | const data = this.props.data; 12 | 13 | const pos = this.props.showPos && data.pos ? ( 14 | { data.pos } 15 | ) : ''; 16 | 17 | let word = data.text; 18 | const article = getArticle(data, this.props.lang); 19 | if (article) word = article + ' ' + word; 20 | 21 | const extra = (data.fl || data.num || data.gen) ? ( 22 | 23 | { data.fl || '' } 24 | { data.fl && (data.num || data.gen) ? ', ' : '' } 25 | { data.num || data.gen || '' } 26 | 27 | ) : ''; 28 | 29 | const transcription = data.ts ? ( 30 | { `${ data.ts }` } 31 | ) : ''; 32 | 33 | return ( 34 |
35 |
36 | { word } 37 | 38 | { pos } 39 | 40 | { extra || transcription } 41 |
42 | 43 | { extra ? ( 44 |
{ transcription }
45 | ) : '' } 46 | 47 |
48 | 49 |
50 |
51 | ); 52 | } 53 | 54 | render() { 55 | const data = this.props.data; 56 | const trs = (data.tr || []).slice(0, maxTrs); 57 | 58 | const trTexts = trs.map(item => item.text).filter(Boolean); 59 | const list = trTexts.some(tr => splitWords(tr).length > 5) ? 60 | trTexts.slice(0, maxLongTrs).map((tr, i) => ( 61 |
{ tr }
62 | )) : trTexts.join('; '); 63 | 64 | const trExamples = []; 65 | trs.forEach(tr => { 66 | tr.ex && tr.ex.forEach(ex => trExamples.push(ex.text)); 67 | }); 68 | const examplesList = trExamples.slice(0, maxLongTrs).join('; '); 69 | 70 | return ( 71 |
72 | { this.renderHeading() } 73 | 74 |
{ list }
75 | 76 | { examplesList ? ( 77 |
78 | E. g.  79 | { examplesList } 80 |
81 | ) : '' } 82 |
83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/content/components/Popup/Popup.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import getCaretCoordinates from 'textarea-caret'; 4 | import { getContext } from '../../services/text-utils.js'; 5 | import Main from '../Main/Main.jsx'; 6 | 7 | 8 | function extractWord(sel) { 9 | return sel.toString(); 10 | } 11 | 12 | function extractContext(sel, word) { 13 | return getContext(word, (sel.focusNode.parentNode || sel.focusNode).textContent); 14 | } 15 | 16 | function getPosition(sel) { 17 | const scrollTop = document.documentElement.scrollTop || document.body.scrollTop; 18 | const scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft; 19 | const range = sel.getRangeAt(0); 20 | const isInInput = sel.anchorNode.contains(document.activeElement) && 21 | [ 'textarea', 'input' ].includes(document.activeElement.tagName.toLowerCase()); 22 | const position = {}; 23 | 24 | if (isInInput) { 25 | const input = document.activeElement; 26 | const coordsStart = getCaretCoordinates(input, input.selectionStart); 27 | const coordsEnd = getCaretCoordinates(input, input.selectionEnd); 28 | const bbox = input.getBoundingClientRect(); 29 | position.left = bbox.left + coordsStart.left; 30 | position.right = bbox.left + coordsEnd.left; 31 | position.top = bbox.top + coordsStart.top; 32 | position.bottom = bbox.top + coordsStart.top + 20; 33 | } else { 34 | const startBbox = range.getBoundingClientRect(); 35 | position.left = startBbox.left; 36 | 37 | // Collapse the selection range horizontally to align to the right 38 | // NB: do this after caching the original bbox 39 | const endRange = range.cloneRange(); 40 | endRange.collapse(); 41 | const endBbox = endRange.getBoundingClientRect(); 42 | position.right = endBbox.left; 43 | position.top = endBbox.top; 44 | position.bottom = endBbox.bottom; 45 | } 46 | 47 | return { 48 | left: Math.round(position.left + scrollLeft), 49 | right: Math.round(position.right + scrollLeft), 50 | top: Math.round(position.top + scrollTop), 51 | bottom: Math.round(position.bottom + scrollTop) 52 | }; 53 | } 54 | 55 | function createDiv({ left, right, top, bottom }) { 56 | const padding = 3; 57 | const div = document.createElement('div'); 58 | 59 | div.style.position = 'absolute'; 60 | div.style.zIndex = '1000'; 61 | div.style.left = `${ left }px`; 62 | div.style.top = `${ top - padding }px`; 63 | div.style.width = `${ (right - left) }px`; 64 | div.style.paddingTop = `${ padding + (bottom - top) }px`; 65 | 66 | document.body.appendChild(div); 67 | 68 | return div; 69 | } 70 | 71 | export default class Popup { 72 | constructor(sel, shouldSave = false) { 73 | const maxWait = 100; 74 | const start = Date.now(); 75 | const word = extractWord(sel); 76 | const context = extractContext(sel, word); 77 | const pos = getPosition(sel); 78 | 79 | this.isDismissable = true; 80 | const onLoad = () => this.isDismissable = (Date.now() - start) <= maxWait; 81 | 82 | this.div = createDiv(pos); 83 | 84 | this.div.addEventListener('mouseleave', () => { 85 | if (this.isDismissable) this.remove(); 86 | }); 87 | 88 | ReactDOM.render( 89 |
, 90 | this.div 91 | ); 92 | } 93 | 94 | remove() { 95 | if (!this.div) return; 96 | 97 | ReactDOM.unmountComponentAtNode(this.div); 98 | this.div.remove(); 99 | this.div = null; 100 | } 101 | } 102 | 103 | -------------------------------------------------------------------------------- /src/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fluentcards Options 5 | 6 | 32 | 33 | 34 | 35 |
36 |

37 | 40 | 41 |

42 | 71 |
72 |

73 | 74 |

75 | 78 | 79 |

80 | 84 | 85 | 89 | 90 | 94 | 95 | 99 | 100 | 104 | 105 | 109 | 110 | 114 |
115 |

116 | 117 |

118 | 119 | 120 |

121 |
122 | 123 |

124 | Powered by 125 | Yandex.Dictionary 126 | and 127 | WordsAPI 128 | 129 |

130 | 131 | 132 | 133 | 134 | --------------------------------------------------------------------------------