Replace content scripts with React

This commit is contained in:
sienori 2019-02-22 01:43:45 +09:00
parent 611658c275
commit c23837e6e1
15 changed files with 562 additions and 91 deletions

36
package-lock.json generated
View file

@ -1137,6 +1137,12 @@
"uri-js": "^4.2.1"
}
},
"ajv-errors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
"integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
"dev": true
},
"ajv-keywords": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.2.0.tgz",
@ -5367,6 +5373,12 @@
"brorand": "^1.0.1"
}
},
"mime": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.4.0.tgz",
"integrity": "sha512-ikBcWwyqXQSHKtciCcctu9YfPbFYZ4+gbHEmE0Q8jzcTYQg5dHCr3g2wwAZjPoJfQVXZq6KXAjpXOTf5/cjT7w==",
"dev": true
},
"mime-db": {
"version": "1.36.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.36.0.tgz",
@ -9705,6 +9717,30 @@
}
}
},
"url-loader": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-1.1.2.tgz",
"integrity": "sha512-dXHkKmw8FhPqu8asTc1puBfe3TehOCo2+RmOOev5suNCIYBcT626kxiWg1NBVkwc4rO8BGa7gP70W7VXuqHrjg==",
"dev": true,
"requires": {
"loader-utils": "^1.1.0",
"mime": "^2.0.3",
"schema-utils": "^1.0.0"
},
"dependencies": {
"schema-utils": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
"integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
"dev": true,
"requires": {
"ajv": "^6.1.0",
"ajv-errors": "^1.0.0",
"ajv-keywords": "^3.1.0"
}
}
}
},
"use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",

View file

@ -25,6 +25,7 @@
"sass-loader": "^7.0.3",
"style-loader": "^0.21.0",
"uglifyjs-webpack-plugin": "^1.2.5",
"url-loader": "^1.1.2",
"webextension-polyfill": "^0.3.1",
"webpack": "^4.10.2",
"webpack-cli": "^3.0.1",

View file

@ -1,86 +1,75 @@
/* Copyright (c) 2017-2018 Sienori All rights reserved.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
let translationHistory = [];
class Translate {
constructor() {
this.history = [];
}
const getHistory = (sourceWord, sourceLang, targetLang) => {
const history = translationHistory.find(
history =>
history.sourceWord == sourceWord &&
history.sourceLang == sourceLang &&
history.targetLang == targetLang &&
history.result.statusText == "OK"
);
return history;
};
getHistory(sourceWord, sourceLang, targetLang) {
const history = this.history.find(
history =>
history.sourceWord == sourceWord &&
history.sourceLang == sourceLang &&
history.targetLang == targetLang &&
history.result.statusText == "OK"
);
return history;
}
const setHistory = (sourceWord, sourceLang, targetLang, formattedResult) => {
translationHistory.push({
sourceWord: sourceWord,
sourceLang: sourceLang,
targetLang: targetLang,
result: formattedResult
});
};
setHistory(sourceWord, sourceLang, targetLang, formattedResult) {
this.history.push({
sourceWord: sourceWord,
sourceLang: sourceLang,
targetLang: targetLang,
result: formattedResult
});
}
const sendRequest = (word, sourceLang, targetLang) => {
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${sourceLang}&tl=${targetLang}&dt=t&dt=bd&dj=1&q=${encodeURIComponent(
word
)}`;
const xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.open("GET", url);
xhr.send();
async translate(sourceWord, sourceLang = "auto", targetLang) {
sourceWord = sourceWord.trim();
const history = this.getHistory(sourceWord, sourceLang, targetLang);
if (history) return history.result;
const result = await this.sendRequest(sourceWord, sourceLang, targetLang);
const formattedResult = this.formatResult(result);
this.setHistory(sourceWord, sourceLang, targetLang, formattedResult);
return formattedResult;
}
sendRequest(word, sourceLang, targetLang) {
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${sourceLang}&tl=${targetLang}&dt=t&dt=bd&dj=1&q=${encodeURIComponent(
word
)}`;
const xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.open("GET", url);
xhr.send();
return new Promise((resolve, reject) => {
xhr.onload = () => {
resolve(xhr);
};
xhr.onerror = () => {
resolve(xhr);
};
});
}
formatResult(result) {
const resultData = {
resultText: "",
candidateText: "",
sourceLanguage: "",
percentage: 0,
statusText: ""
return new Promise((resolve, reject) => {
xhr.onload = () => {
resolve(xhr);
};
xhr.onerror = () => {
resolve(xhr);
};
});
};
resultData.statusText = result.statusText;
if (resultData.statusText !== "OK") return resultData;
const formatResult = result => {
const resultData = {
resultText: "",
candidateText: "",
sourceLanguage: "",
percentage: 0,
statusText: ""
};
resultData.sourceLanguage = result.response.src;
resultData.percentage = result.response.confidence;
resultData.resultText = result.response.sentences.map(sentence => sentence.trans).join("");
if (result.response.dict) {
resultData.candidateText = result.response.dict
.map(dict => `${dict.pos}${dict.pos != "" ? ": " : ""}${dict.terms.join(", ")}\n`)
.join("");
}
resultData.statusText = result.statusText;
if (resultData.statusText !== "OK") return resultData;
return resultData;
resultData.sourceLanguage = result.response.src;
resultData.percentage = result.response.confidence;
resultData.resultText = result.response.sentences.map(sentence => sentence.trans).join("");
if (result.response.dict) {
resultData.candidateText = result.response.dict
.map(dict => `${dict.pos}${dict.pos != "" ? ": " : ""}${dict.terms.join(", ")}\n`)
.join("");
}
}
return resultData;
};
export default async (sourceWord, sourceLang = "auto", targetLang) => {
sourceWord = sourceWord.trim();
const history = getHistory(sourceWord, sourceLang, targetLang);
if (history) return history.result;
const result = await sendRequest(sourceWord, sourceLang, targetLang);
const formattedResult = formatResult(result);
setHistory(sourceWord, sourceLang, targetLang, formattedResult);
return formattedResult;
};

View file

@ -0,0 +1,37 @@
import React from "react";
import { getSettings } from "src/settings/settings";
import "../styles/TranslateButton.scss";
const calcPosition = () => {
const buttonSize = parseInt(getSettings("buttonSize"));
const offset = 10;
switch (getSettings("buttonPosition")) {
case "rightUp":
return { top: -buttonSize - offset, left: offset };
case "rightDown":
return { top: offset, left: offset };
case "leftUp":
return { top: -buttonSize - offset, left: -buttonSize - offset };
case "leftDown":
return { top: offset, left: -buttonSize - offset };
}
};
export default props => {
const { position, shouldShow } = props;
const buttonSize = parseInt(getSettings("buttonSize"));
const { top, left } = calcPosition();
const buttonStyle = {
height: buttonSize,
width: buttonSize,
top: top + position.y,
left: left + position.x
};
return (
<button
style={buttonStyle}
className={`simple-translate-button ${shouldShow ? "isShow" : ""}`}
onClick={props.handleButtonClick}
/>
);
};

View file

@ -0,0 +1,181 @@
import React, { Component } from "react";
import browser from "webextension-polyfill";
import translate from "src/common/translate";
import { initSettings, getSettings, handleSettingsChange } from "src/settings/settings";
import TranslateButton from "./TranslateButton";
import TranslatePanel from "./TranslatePanel";
import "../styles/TranslateContainer.scss";
const getSelectedText = () => {
const element = document.activeElement;
const isInTextField = element.tagName === "INPUT" || element.tagName === "TEXTAREA";
const selectedText = isInTextField
? element.value.substring(element.selectionStart, element.selectionEnd)
: window.getSelection().toString();
return selectedText;
};
const getSelectedPosition = () => {
const element = document.activeElement;
const isInTextField = element.tagName === "INPUT" || element.tagName === "TEXTAREA";
const selectedRect = isInTextField
? element.getBoundingClientRect()
: window
.getSelection()
.getRangeAt(0)
.getBoundingClientRect();
const selectedPosition = {
x: selectedRect.left + selectedRect.width / 2,
y: selectedRect.bottom
};
return selectedPosition;
};
const translateText = async text => {
const targetLang = getSettings("targetLang");
const result = await translate(text, "auto", targetLang);
return result;
};
const matchesTargetLang = async selectedText => {
const targetLang = getSettings("targetLang");
//detectLanguageで判定
const langInfo = await browser.i18n.detectLanguage(selectedText);
const matchsLangsByDetect = langInfo.isReliable && langInfo.languages[0].language === targetLang;
if (matchsLangsByDetect) return true;
//先頭100字を翻訳にかけて判定
const partSelectedText = selectedText.substring(0, 100);
const result = await translateText(partSelectedText);
const matchsLangs = targetLang === result.sourceLanguage && result.percentage > 0;
return matchsLangs;
};
export default class TranslateContainer extends Component {
constructor(props) {
super(props);
this.state = {
isInit: false,
shouldShowButton: false,
buttonPosition: { x: 0, y: 0 },
shouldShowPanel: false,
panelPosition: { x: 0, y: 0 },
resultText: "",
candidateText: ""
};
this.selectedText = "";
this.init();
}
init = async () => {
await initSettings();
this.setState({ isInit: true });
document.addEventListener("mouseup", e => setTimeout(() => this.handleMouseUp(e), 0));
document.addEventListener("keydown", this.handleKeyDown);
browser.storage.onChanged.addListener(handleSettingsChange);
browser.runtime.onMessage.addListener(this.handleMessage);
};
handleMessage = async request => {
switch (request.message) {
case "getTabInfo":
const tabInfo = { url: location.href, selectedText: this.selectedText };
return tabInfo;
case "translateSelectedText":
this.selectedText = getSelectedText();
const position = getSelectedPosition();
if (this.selectedText.length === 0) return;
this.hideButton();
this.showPanel(position);
break;
}
};
handleKeyDown = e => {
if (e.key === "Escape") {
this.hideButton();
this.hidePanel();
}
};
showButton = position => {
this.setState({ shouldShowButton: true, buttonPosition: position });
};
hideButton = () => {
this.setState({ shouldShowButton: false });
};
handleButtonClick = e => {
const position = { x: e.clientX, y: e.clientY };
this.showPanel(position);
this.hideButton();
};
showPanel = async position => {
const result = await translateText(this.selectedText);
this.setState({
shouldShowPanel: true,
panelPosition: position,
resultText: result.resultText,
candidateText: getSettings("ifShowCandidate") ? result.candidateText : ""
});
};
hidePanel = () => {
this.setState({ shouldShowPanel: false });
};
handleMouseUp = e => {
const isLeftClick = e.button === 0;
const isInPasswordField = e.target.tagName === "INPUT" && e.target.type === "password";
const isInThisElement = document.querySelector("#simple-translate").contains(e.target);
if (!isLeftClick) return;
if (isInPasswordField) return;
if (isInThisElement) return;
this.hideButton();
this.hidePanel();
this.selectedText = getSelectedText();
const position = { x: e.clientX, y: e.clientY };
if (this.selectedText.length === 0) return;
this.handleTextSelect(position);
};
handleTextSelect = async position => {
const onSelectBehavior = getSettings("whenSelectText");
if (onSelectBehavior === "dontShowButton") return;
if (getSettings("ifCheckLang")) {
const matchesLang = await matchesTargetLang(this.selectedText);
if (matchesLang) return;
}
if (onSelectBehavior === "showButton") {
this.showButton(position);
} else if (onSelectBehavior === "showPanel") {
this.showPanel(position);
}
};
render = () => {
if (!this.state.isInit) return null;
return (
<div>
<TranslateButton
shouldShow={this.state.shouldShowButton}
position={this.state.buttonPosition}
handleButtonClick={this.handleButtonClick}
/>
<TranslatePanel
shouldShow={this.state.shouldShowPanel}
position={this.state.panelPosition}
resultText={this.state.resultText}
candidateText={this.state.candidateText}
hidePanel={this.hidePanel}
/>
</div>
);
};
}

View file

@ -0,0 +1,116 @@
import React, { Component } from "react";
import ReactDOM from "react-dom";
import { getSettings } from "src/settings/settings";
import "../styles/TranslatePanel.scss";
export default class TranslatePanel extends Component {
constructor(props) {
super(props);
this.state = {
panelPosition: { x: 0, y: 0 },
panelWidth: 0,
panelHeight: 0,
shouldResize: true
};
this.isFirst = true;
}
calcPosition = () => {
const maxWidth = parseInt(getSettings("width"));
const maxHeight = parseInt(getSettings("height"));
const wrapper = ReactDOM.findDOMNode(this.refs.wrapper);
const panelWidth = Math.min(wrapper.clientWidth, maxWidth);
const panelHeight = Math.min(wrapper.clientHeight, maxHeight);
const windowWidth = document.documentElement.clientWidth;
const windowHeight = document.documentElement.clientHeight;
const offset = 10;
//TODO: パネルの表示位置オプション
let position = {
x: this.props.position.x - panelWidth / 2,
y: this.props.position.y + offset
};
if (position.x + panelWidth > windowWidth - offset) {
position.x = windowWidth - panelWidth - offset;
}
if (position.y + panelHeight > windowHeight - offset) {
position.y = windowHeight - panelHeight - offset;
}
if (position.x < 0 + offset) {
position.x = offset;
}
if (position.y < 0 + offset) {
position.y = offset;
}
return position;
};
calcSize = () => {
const wrapper = ReactDOM.findDOMNode(this.refs.wrapper);
const wrapperWidth = wrapper.clientWidth;
const wrapperHeight = wrapper.clientHeight;
return { panelWidth: wrapperWidth, panelHeight: wrapperHeight };
};
componentWillReceiveProps = nextProps => {
const isChangedContents =
this.props.resultText !== nextProps.resultText ||
this.props.candidateText !== nextProps.candidateText;
if (isChangedContents && nextProps.shouldShow) this.setState({ shouldResize: true });
};
componentDidUpdate = () => {
if (!this.state.shouldResize || !this.props.shouldShow) return;
const panelPosition = this.calcPosition();
const { panelWidth, panelHeight } = this.calcSize();
this.setState({
shouldResize: false,
panelPosition: panelPosition,
panelWidth: panelWidth,
panelHeight: panelHeight
});
};
render = () => {
const { width, height } = this.state.shouldResize
? { width: parseInt(getSettings("width")), height: parseInt(getSettings("height")) }
: { width: this.state.panelWidth, height: this.state.panelHeight };
const panelStyles = {
width: width,
height: height,
top: this.state.panelPosition.y,
left: this.state.panelPosition.x,
fontSize: parseInt(getSettings("fontSize")),
backgroundColor: getSettings("bgColor")
};
const wrapperStyles = {
overflow: this.state.shouldResize ? "hidden" : "auto"
};
const resultStyles = {
color: getSettings("resultFontColor")
};
const candidateStyles = {
color: getSettings("candidateFontColor")
};
return (
<div
className={`simple-translate-panel ${this.props.shouldShow ? "isShow" : ""}`}
ref="panel"
style={panelStyles}
>
<div className="simple-translate-result-wrapper" ref="wrapper" style={wrapperStyles}>
<p className="simple-translate-result" style={resultStyles}>
{this.props.resultText}
</p>
<p className="simple-translate-candidate" style={candidateStyles}>
{this.props.candidateText}
</p>
</div>
</div>
);
};
}

6
src/content/index.js Normal file
View file

@ -0,0 +1,6 @@
import React from "react";
import ReactDOM from "react-dom";
import TranslateContainer from "./components/TranslateContainer";
document.body.insertAdjacentHTML("afterend", "<div id='simple-translate'></div>");
ReactDOM.render(<TranslateContainer />, document.getElementById("simple-translate"));

View file

@ -0,0 +1,40 @@
#simple-translate {
.simple-translate-button {
all: initial;
background-color: #fff;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(0, 0, 0, 0.08);
border-radius: 10%;
background-image: url("../../icons/512.png");
background-size: 75%;
background-repeat: no-repeat;
background-position: center;
height: 22px;
width: 22px;
position: fixed;
z-index: 2147483647;
cursor: pointer;
transition-property: opacity, visibility;
transition-duration: 200ms;
opacity: 0;
visibility: hidden;
&.isShow {
opacity: 1;
visibility: visible;
transition-duration: 0ms;
animation: simple-translate-showButton 200ms;
}
}
@keyframes simple-translate-showButton {
0% {
transform: scale3d(1, 1, 1);
}
50% {
transform: scale3d(1.1, 1.1, 1.1);
}
100% {
transform: scale3d(1, 1, 1);
}
}
}

View file

@ -0,0 +1,8 @@
:root {
--simple-translate-main-text: #0c0c0d;
--simple-translate-sub-text: #737373;
--simple-translate-line: #ededf0;
--simple-translate-button: #d7d7db;
--simple-translate-highlight: #5595ff;
--simple-translate-main-bg: #ffffff;
}

View file

@ -0,0 +1,56 @@
#simple-translate {
.simple-translate-panel {
all: initial;
position: fixed;
background-color: var(--simple-translate-main-bg);
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(0, 0, 0, 0.08);
border-radius: 3px;
z-index: 2147483646;
transition-property: opacity, visibility;
transition-duration: 200ms;
opacity: 0;
visibility: hidden;
&.isShow {
opacity: 1;
visibility: visible;
}
.simple-translate-result-wrapper {
padding: 10px 18px;
box-sizing: border-box;
width: max-content;
height: max-content;
max-width: 100%;
max-height: 100%;
p {
all: initial;
display: block;
margin: 0;
font-family: "Segoe UI", "San Francisco", "Ubuntu", "Fira Sans", "Roboto", "Arial",
"Helvetica", sans-serif !important;
text-align: left;
word-wrap: break-word;
font-size: inherit;
line-height: 150%;
visibility: inherit;
opacity: inherit;
&::-moz-selection {
background: var(--simple-translate-line);
}
&.simple-translate-result {
color: var(--simple-translate-main-text);
}
&.simple-translate-candidate {
color: var(--simple-translate-sub-text);
margin-top: 1em;
&:empty {
margin-top: 0;
}
}
}
}
}
}

View file

@ -43,8 +43,7 @@
{
"all_frames": true,
"matches": ["http://*/*", "https://*/*", "<all_urls>"],
"css": ["content/simple-translate.css"],
"js": ["content/simple-translate.js"]
"js": ["content/content.js"]
}
]
}

View file

@ -49,8 +49,7 @@
{
"all_frames": true,
"matches": ["http://*/*", "https://*/*", "<all_urls>"],
"css": ["content/simple-translate.css"],
"js": ["content/simple-translate.js"]
"js": ["content/content.js"]
}
]
}

View file

@ -1,4 +1,5 @@
/* Copyright (c) 2018 Kamil Mikosz
/* Copyright (c) 2018 Kamil Mikosz
* Copyright (c) 2019 Sienori
* Released under the MIT license.
* see https://opensource.org/licenses/MIT */
@ -72,6 +73,11 @@ const generalConfig = {
}
}
]
},
{
test: /\.(png|jpg|gif)$/,
loader: "url-loader",
options: {}
}
]
}

View file

@ -79,6 +79,11 @@ const generalConfig = {
}
}
]
},
{
test: /\.(png|jpg|gif)$/,
loader: "url-loader",
options: {}
}
]
}

View file

@ -34,7 +34,7 @@ const getEntry = (sourceDir = "src") => {
return {
popup: path.resolve(__dirname, `${sourceDir}/popup/index.js`),
options: path.resolve(__dirname, `${sourceDir}/options/index.js`),
content: path.resolve(__dirname, `${sourceDir}/content/simple-translate.js`),
content: path.resolve(__dirname, `${sourceDir}/content/index.js`),
background: path.resolve(__dirname, `${sourceDir}/background/background.js`)
};
};
@ -49,10 +49,6 @@ const getCopyPlugins = (browserDir, outputDir = "dev", sourceDir = "src") => [
from: `${sourceDir}/_locales`,
to: path.resolve(__dirname, `${outputDir}/${browserDir}/_locales`)
},
{
from: `${sourceDir}/content/simple-translate.css`,
to: path.resolve(__dirname, `${outputDir}/${browserDir}/content/simple-translate.css`)
},
{
from: `${sourceDir}/manifest-chrome.json`,
to: path.resolve(__dirname, `${outputDir}/${browserDir}/manifest.json`)
@ -70,10 +66,6 @@ const getFirefoxCopyPlugins = (browserDir, outputDir = "dev", sourceDir = "src")
from: `${sourceDir}/_locales`,
to: path.resolve(__dirname, `${outputDir}/${browserDir}/_locales`)
},
{
from: `${sourceDir}/content/simple-translate.css`,
to: path.resolve(__dirname, `${outputDir}/${browserDir}/content/simple-translate.css`)
},
{
from: `${sourceDir}/manifest-firefox.json`,
to: path.resolve(__dirname, `${outputDir}/${browserDir}/manifest.json`)