aboutsummaryrefslogtreecommitdiff
path: root/ext/fg/js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/fg/js')
-rw-r--r--ext/fg/js/document.js283
-rw-r--r--ext/fg/js/float.js2
-rw-r--r--ext/fg/js/frontend.js16
-rw-r--r--ext/fg/js/popup.js149
-rw-r--r--ext/fg/js/source.js40
-rw-r--r--ext/fg/js/util.js5
6 files changed, 387 insertions, 108 deletions
diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js
index 86396a8a..bd876e5d 100644
--- a/ext/fg/js/document.js
+++ b/ext/fg/js/document.js
@@ -17,77 +17,110 @@
*/
-const IS_FIREFOX = /Firefox/.test(navigator.userAgent);
+const REGEX_TRANSPARENT_COLOR = /rgba\s*\([^\)]*,\s*0(?:\.0+)?\s*\)/;
-function docOffsetCalc(element) {
- const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;
- const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft;
-
- const clientTop = document.documentElement.clientTop || document.body.clientTop || 0;
- const clientLeft = document.documentElement.clientLeft || document.body.clientLeft || 0;
+function docSetImposterStyle(style, propertyName, value) {
+ style.setProperty(propertyName, value, 'important');
+}
- const rect = element.getBoundingClientRect();
- const top = Math.round(rect.top + scrollTop - clientTop);
- const left = Math.round(rect.left + scrollLeft - clientLeft);
+function docImposterCreate(element, isTextarea) {
+ const elementStyle = window.getComputedStyle(element);
+ const elementRect = element.getBoundingClientRect();
+ const documentRect = document.documentElement.getBoundingClientRect();
+ const left = elementRect.left - documentRect.left;
+ const top = elementRect.top - documentRect.top;
+
+ // Container
+ const container = document.createElement('div');
+ const containerStyle = container.style;
+ docSetImposterStyle(containerStyle, 'all', 'initial');
+ docSetImposterStyle(containerStyle, 'position', 'absolute');
+ docSetImposterStyle(containerStyle, 'left', '0');
+ docSetImposterStyle(containerStyle, 'top', '0');
+ docSetImposterStyle(containerStyle, 'width', `${documentRect.width}px`);
+ docSetImposterStyle(containerStyle, 'height', `${documentRect.height}px`);
+ docSetImposterStyle(containerStyle, 'overflow', 'hidden');
+ docSetImposterStyle(containerStyle, 'opacity', '0');
+
+ docSetImposterStyle(containerStyle, 'pointer-events', 'none');
+ docSetImposterStyle(containerStyle, 'z-index', '2147483646');
+
+ // Imposter
+ const imposter = document.createElement('div');
+ const imposterStyle = imposter.style;
- return {top, left};
-}
+ imposter.innerText = element.value;
-function docImposterCreate(element) {
- const styleProps = window.getComputedStyle(element);
- const stylePairs = [];
- for (const key of styleProps) {
- stylePairs.push(`${key}: ${styleProps[key]};`);
+ for (let i = 0, ii = elementStyle.length; i < ii; ++i) {
+ const property = elementStyle[i];
+ docSetImposterStyle(imposterStyle, property, elementStyle.getPropertyValue(property));
+ }
+ docSetImposterStyle(imposterStyle, 'position', 'absolute');
+ docSetImposterStyle(imposterStyle, 'top', `${top}px`);
+ docSetImposterStyle(imposterStyle, 'left', `${left}px`);
+ docSetImposterStyle(imposterStyle, 'margin', '0');
+ docSetImposterStyle(imposterStyle, 'pointer-events', 'auto');
+
+ if (isTextarea) {
+ if (elementStyle.overflow === 'visible') {
+ docSetImposterStyle(imposterStyle, 'overflow', 'auto');
+ }
+ } else {
+ docSetImposterStyle(imposterStyle, 'overflow', 'hidden');
+ docSetImposterStyle(imposterStyle, 'white-space', 'nowrap');
+ docSetImposterStyle(imposterStyle, 'line-height', elementStyle.height);
}
- const offset = docOffsetCalc(element);
- const imposter = document.createElement('div');
- imposter.className = 'yomichan-imposter';
- imposter.innerText = element.value;
- imposter.style.cssText = stylePairs.join('\n');
- imposter.style.position = 'absolute';
- imposter.style.top = `${offset.top}px`;
- imposter.style.left = `${offset.left}px`;
- imposter.style.opacity = 0;
- imposter.style.zIndex = 2147483646;
- if (element.nodeName === 'TEXTAREA' && styleProps.overflow === 'visible') {
- imposter.style.overflow = 'auto';
+ container.appendChild(imposter);
+ document.body.appendChild(container);
+
+ // Adjust size
+ const imposterRect = imposter.getBoundingClientRect();
+ if (imposterRect.width !== elementRect.width || imposterRect.height !== elementRect.height) {
+ const width = parseFloat(elementStyle.width) + (elementRect.width - imposterRect.width);
+ const height = parseFloat(elementStyle.height) + (elementRect.height - imposterRect.height);
+ docSetImposterStyle(imposterStyle, 'width', `${width}px`);
+ docSetImposterStyle(imposterStyle, 'height', `${height}px`);
}
- document.body.appendChild(imposter);
imposter.scrollTop = element.scrollTop;
imposter.scrollLeft = element.scrollLeft;
- return imposter;
-}
-
-function docImposterDestroy() {
- for (const element of document.getElementsByClassName('yomichan-imposter')) {
- element.parentNode.removeChild(element);
- }
+ return [imposter, container];
}
-function docRangeFromPoint(point) {
- const element = document.elementFromPoint(point.x, point.y);
+function docRangeFromPoint({x, y}, options) {
+ const elements = document.elementsFromPoint(x, y);
let imposter = null;
- if (element) {
+ let imposterContainer = null;
+ if (elements.length > 0) {
+ const element = elements[0];
switch (element.nodeName) {
case 'IMG':
case 'BUTTON':
return new TextSourceElement(element);
case 'INPUT':
+ [imposter, imposterContainer] = docImposterCreate(element, false);
+ break;
case 'TEXTAREA':
- imposter = docImposterCreate(element);
+ [imposter, imposterContainer] = docImposterCreate(element, true);
break;
}
}
- const range = document.caretRangeFromPoint(point.x, point.y);
- if (imposter !== null) {
- imposter.style.zIndex = -2147483646;
+ const range = caretRangeFromPointExt(x, y, options.scanning.deepDomScan ? elements : []);
+ if (range !== null) {
+ if (imposter !== null) {
+ docSetImposterStyle(imposterContainer.style, 'z-index', '-2147483646');
+ docSetImposterStyle(imposter.style, 'pointer-events', 'none');
+ }
+ return new TextSourceRange(range, '', imposterContainer);
+ } else {
+ if (imposterContainer !== null) {
+ imposterContainer.parentNode.removeChild(imposterContainer);
+ }
+ return null;
}
-
- return range !== null && isPointInRange(point, range) ? new TextSourceRange(range) : null;
}
function docSentenceExtract(source, extent) {
@@ -161,32 +194,158 @@ function docSentenceExtract(source, extent) {
};
}
-function isPointInRange(point, range) {
- if (IS_FIREFOX) {
- // Always return true on Firefox due to an issue where range.getClientRects()
- // does not return a correct set of rects for characters at the beginning of a line.
- return true;
+function isPointInRange(x, y, range) {
+ // Require a text node to start
+ if (range.startContainer.nodeType !== Node.TEXT_NODE) {
+ return false;
}
- const y = point.y - 2;
- for (const rect of range.getClientRects()) {
- if (y <= rect.bottom) {
+ // Scan forward
+ const nodePre = range.endContainer;
+ const offsetPre = range.endOffset;
+ try {
+ const {node, offset, content} = TextSourceRange.seekForward(range.endContainer, range.endOffset, 1);
+ range.setEnd(node, offset);
+
+ if (!isWhitespace(content) && isPointInAnyRect(x, y, range.getClientRects())) {
return true;
}
+ } finally {
+ range.setEnd(nodePre, offsetPre);
+ }
+
+ // Scan backward
+ const {node, offset, content} = TextSourceRange.seekBackward(range.startContainer, range.startOffset, 1);
+ range.setStart(node, offset);
+
+ if (!isWhitespace(content) && isPointInAnyRect(x, y, range.getClientRects())) {
+ // This purposefully leaves the starting offset as modified and sets the range length to 0.
+ range.setEnd(node, offset);
+ return true;
}
+ // No match
+ return false;
+}
+
+function isWhitespace(string) {
+ return string.trim().length === 0;
+}
+
+function isPointInAnyRect(x, y, rects) {
+ for (const rect of rects) {
+ if (isPointInRect(x, y, rect)) {
+ return true;
+ }
+ }
return false;
}
-if (typeof document.caretRangeFromPoint !== 'function') {
- document.caretRangeFromPoint = (x, y) => {
- const position = document.caretPositionFromPoint(x, y);
- if (position && position.offsetNode && position.offsetNode.nodeType === Node.TEXT_NODE) {
+function isPointInRect(x, y, rect) {
+ return (
+ x >= rect.left && x < rect.right &&
+ y >= rect.top && y < rect.bottom);
+}
+
+const caretRangeFromPoint = (() => {
+ if (typeof document.caretRangeFromPoint === 'function') {
+ // Chrome, Edge
+ return (x, y) => document.caretRangeFromPoint(x, y);
+ }
+
+ if (typeof document.caretPositionFromPoint === 'function') {
+ // Firefox
+ return (x, y) => {
+ const position = document.caretPositionFromPoint(x, y);
+ const node = position.offsetNode;
+ if (node === null) {
+ return null;
+ }
+
const range = document.createRange();
- range.setStart(position.offsetNode, position.offset);
- range.setEnd(position.offsetNode, position.offset);
+ const offset = (node.nodeType === Node.TEXT_NODE ? position.offset : 0);
+ range.setStart(node, offset);
+ range.setEnd(node, offset);
return range;
+ };
+ }
+
+ // No support
+ return () => null;
+})();
+
+function caretRangeFromPointExt(x, y, elements) {
+ const modifications = [];
+ try {
+ let i = 0;
+ let startContinerPre = null;
+ while (true) {
+ const range = caretRangeFromPoint(x, y);
+ if (range === null) {
+ return null;
+ }
+
+ const startContainer = range.startContainer;
+ if (startContinerPre !== startContainer) {
+ if (isPointInRange(x, y, range)) {
+ return range;
+ }
+ startContinerPre = startContainer;
+ }
+
+ i = disableTransparentElement(elements, i, modifications);
+ if (i < 0) {
+ return null;
+ }
}
- return null;
- };
+ } finally {
+ if (modifications.length > 0) {
+ restoreElementStyleModifications(modifications);
+ }
+ }
+}
+
+function disableTransparentElement(elements, i, modifications) {
+ while (true) {
+ if (i >= elements.length) {
+ return -1;
+ }
+
+ const element = elements[i++];
+ if (isElementTransparent(element)) {
+ const style = element.hasAttribute('style') ? element.getAttribute('style') : null;
+ modifications.push({element, style});
+ element.style.pointerEvents = 'none';
+ return i;
+ }
+ }
+}
+
+function restoreElementStyleModifications(modifications) {
+ for (const {element, style} of modifications) {
+ if (style === null) {
+ element.removeAttribute('style');
+ } else {
+ element.setAttribute('style', style);
+ }
+ }
+}
+
+function isElementTransparent(element) {
+ if (
+ element === document.body ||
+ element === document.documentElement
+ ) {
+ return false;
+ }
+ const style = window.getComputedStyle(element);
+ return (
+ parseFloat(style.opacity) < 0 ||
+ style.visibility === 'hidden' ||
+ (style.backgroundImage === 'none' && isColorTransparent(style.backgroundColor))
+ );
+}
+
+function isColorTransparent(cssColor) {
+ return REGEX_TRANSPARENT_COLOR.test(cssColor);
}
diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js
index 090839a1..c0ec8a15 100644
--- a/ext/fg/js/float.js
+++ b/ext/fg/js/float.js
@@ -23,7 +23,7 @@ class DisplayFloat extends Display {
this.autoPlayAudioTimer = null;
this.styleNode = null;
- this.dependencies = {...this.dependencies, ...{docRangeFromPoint, docSentenceExtract}};
+ this.dependencies = Object.assign({}, this.dependencies, {docRangeFromPoint, docSentenceExtract});
$(window).on('message', utilAsync(this.onMessage.bind(this)));
}
diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js
index 3c5f2ac8..8a5c48d0 100644
--- a/ext/fg/js/frontend.js
+++ b/ext/fg/js/frontend.js
@@ -285,7 +285,7 @@ class Frontend {
return;
}
- const textSource = docRangeFromPoint(point);
+ const textSource = docRangeFromPoint(point, this.options);
let hideResults = !textSource || !textSource.containsPoint(point);
let searched = false;
let success = false;
@@ -301,16 +301,21 @@ class Frontend {
} catch (e) {
if (window.yomichan_orphaned) {
if (textSource && this.options.scanning.modifier !== 'none') {
- this.popup.showOrphaned(textSource.getRect(), this.options);
+ this.popup.showOrphaned(
+ textSource.getRect(),
+ textSource.getWritingMode(),
+ this.options
+ );
}
} else {
this.onError(e);
}
} finally {
+ if (textSource !== null) {
+ textSource.cleanup();
+ }
if (hideResults && this.options.scanning.autoHideResults) {
this.searchClear();
- } else {
- docImposterDestroy();
}
this.pendingLookup = false;
@@ -332,6 +337,7 @@ class Frontend {
const url = window.location.href;
this.popup.termsShow(
textSource.getRect(),
+ textSource.getWritingMode(),
definitions,
this.options,
{sentence, url, focus}
@@ -357,6 +363,7 @@ class Frontend {
const url = window.location.href;
this.popup.kanjiShow(
textSource.getRect(),
+ textSource.getWritingMode(),
definitions,
this.options,
{sentence, url, focus}
@@ -371,7 +378,6 @@ class Frontend {
}
searchClear() {
- docImposterDestroy();
this.popup.hide();
this.popup.clearAutoPlayTimer();
diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js
index 18dc0386..86ce575d 100644
--- a/ext/fg/js/popup.js
+++ b/ext/fg/js/popup.js
@@ -48,59 +48,132 @@ class Popup {
return this.injected;
}
- async show(elementRect, options) {
+ async show(elementRect, writingMode, options) {
await this.inject(options);
- const containerStyle = window.getComputedStyle(this.container);
- const containerHeight = parseInt(containerStyle.height);
- const containerWidth = parseInt(containerStyle.width);
+ const optionsGeneral = options.general;
+ const container = this.container;
+ const containerRect = container.getBoundingClientRect();
+ const getPosition = (
+ writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ?
+ Popup.getPositionForHorizontalText :
+ Popup.getPositionForVerticalText
+ );
- const limitX = document.body.clientWidth;
- const limitY = window.innerHeight;
+ const [x, y, width, height, below] = getPosition(
+ elementRect,
+ Math.max(containerRect.width, optionsGeneral.popupWidth),
+ Math.max(containerRect.height, optionsGeneral.popupHeight),
+ document.body.clientWidth,
+ window.innerHeight,
+ optionsGeneral,
+ writingMode
+ );
- let x = elementRect.left + options.general.popupHorizontalOffset;
- let width = Math.max(containerWidth, options.general.popupWidth);
- const overflowX = Math.max(x + width - limitX, 0);
+ container.classList.toggle('yomichan-float-full-width', optionsGeneral.popupDisplayMode === 'full-width');
+ container.classList.toggle('yomichan-float-above', !below);
+ container.style.left = `${x}px`;
+ container.style.top = `${y}px`;
+ container.style.width = `${width}px`;
+ container.style.height = `${height}px`;
+ container.style.visibility = 'visible';
+ }
+
+ static getPositionForHorizontalText(elementRect, width, height, maxWidth, maxHeight, optionsGeneral) {
+ let x = elementRect.left + optionsGeneral.popupHorizontalOffset;
+ const overflowX = Math.max(x + width - maxWidth, 0);
if (overflowX > 0) {
if (x >= overflowX) {
x -= overflowX;
} else {
- width = limitX;
+ width = maxWidth;
x = 0;
}
}
- let above = false;
- let y = 0;
- let height = Math.max(containerHeight, options.general.popupHeight);
- const yBelow = elementRect.bottom + options.general.popupVerticalOffset;
- const yAbove = elementRect.top - options.general.popupVerticalOffset;
- const overflowBelow = Math.max(yBelow + height - limitY, 0);
- const overflowAbove = Math.max(height - yAbove, 0);
- if (overflowBelow > 0 || overflowAbove > 0) {
- if (overflowBelow < overflowAbove) {
- height = Math.max(height - overflowBelow, 0);
- y = yBelow;
+ const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below');
+
+ const verticalOffset = optionsGeneral.popupVerticalOffset;
+ const [y, h, below] = Popup.limitGeometry(
+ elementRect.top - verticalOffset,
+ elementRect.bottom + verticalOffset,
+ height,
+ maxHeight,
+ preferBelow
+ );
+
+ return [x, y, width, h, below];
+ }
+
+ static getPositionForVerticalText(elementRect, width, height, maxWidth, maxHeight, optionsGeneral, writingMode) {
+ const preferRight = Popup.isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode);
+ const horizontalOffset = optionsGeneral.popupHorizontalOffset2;
+ const verticalOffset = optionsGeneral.popupVerticalOffset2;
+
+ const [x, w] = Popup.limitGeometry(
+ elementRect.left - horizontalOffset,
+ elementRect.right + horizontalOffset,
+ width,
+ maxWidth,
+ preferRight
+ );
+ const [y, h, below] = Popup.limitGeometry(
+ elementRect.bottom - verticalOffset,
+ elementRect.top + verticalOffset,
+ height,
+ maxHeight,
+ true
+ );
+ return [x, y, w, h, below];
+ }
+
+ static isVerticalTextPopupOnRight(positionPreference, writingMode) {
+ switch (positionPreference) {
+ case 'before':
+ return !Popup.isWritingModeLeftToRight(writingMode);
+ case 'after':
+ return Popup.isWritingModeLeftToRight(writingMode);
+ case 'left':
+ return false;
+ case 'right':
+ return true;
+ }
+ }
+
+ static isWritingModeLeftToRight(writingMode) {
+ switch (writingMode) {
+ case 'vertical-lr':
+ case 'sideways-lr':
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ static limitGeometry(positionBefore, positionAfter, size, limit, preferAfter) {
+ let after = preferAfter;
+ let position = 0;
+ const overflowBefore = Math.max(0, size - positionBefore);
+ const overflowAfter = Math.max(0, positionAfter + size - limit);
+ if (overflowAfter > 0 || overflowBefore > 0) {
+ if (overflowAfter < overflowBefore) {
+ size = Math.max(0, size - overflowAfter);
+ position = positionAfter;
+ after = true;
} else {
- height = Math.max(height - overflowAbove, 0);
- y = Math.max(yAbove - height, 0);
- above = true;
+ size = Math.max(0, size - overflowBefore);
+ position = Math.max(0, positionBefore - size);
+ after = false;
}
} else {
- y = yBelow;
+ position = preferAfter ? positionAfter : positionBefore - size;
}
- this.container.classList.toggle('yomichan-float-full-width', options.general.popupDisplayMode === 'full-width');
- this.container.classList.toggle('yomichan-float-above', above);
- this.container.style.left = `${x}px`;
- this.container.style.top = `${y}px`;
- this.container.style.width = `${width}px`;
- this.container.style.height = `${height}px`;
- this.container.style.visibility = 'visible';
+ return [position, size, after];
}
- async showOrphaned(elementRect, options) {
- await this.show(elementRect, options);
+ async showOrphaned(elementRect, writingMode, options) {
+ await this.show(elementRect, writingMode, options);
this.invokeApi('orphaned');
}
@@ -136,13 +209,13 @@ class Popup {
return contained;
}
- async termsShow(elementRect, definitions, options, context) {
- await this.show(elementRect, options);
+ async termsShow(elementRect, writingMode, definitions, options, context) {
+ await this.show(elementRect, writingMode, options);
this.invokeApi('termsShow', {definitions, options, context});
}
- async kanjiShow(elementRect, definitions, options, context) {
- await this.show(elementRect, options);
+ async kanjiShow(elementRect, writingMode, definitions, options, context) {
+ await this.show(elementRect, writingMode, options);
this.invokeApi('kanjiShow', {definitions, options, context});
}
diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js
index a360b331..e724488d 100644
--- a/ext/fg/js/source.js
+++ b/ext/fg/js/source.js
@@ -25,13 +25,20 @@ const IGNORE_TEXT_PATTERN = /\u200c/;
*/
class TextSourceRange {
- constructor(range, content='') {
+ constructor(range, content, imposterContainer) {
this.range = range;
this.content = content;
+ this.imposterContainer = imposterContainer;
}
clone() {
- return new TextSourceRange(this.range.cloneRange(), this.content);
+ return new TextSourceRange(this.range.cloneRange(), this.content, this.imposterContainer);
+ }
+
+ cleanup() {
+ if (this.imposterContainer !== null && this.imposterContainer.parentNode !== null) {
+ this.imposterContainer.parentNode.removeChild(this.imposterContainer);
+ }
}
text() {
@@ -61,6 +68,10 @@ class TextSourceRange {
return this.range.getBoundingClientRect();
}
+ getWritingMode() {
+ return TextSourceRange.getElementWritingMode(TextSourceRange.getParentElement(this.range.startContainer));
+ }
+
getPaddedRect() {
const range = this.range.cloneRange();
const startOffset = range.startOffset;
@@ -204,6 +215,23 @@ class TextSourceRange {
return state.remainder > 0;
}
+
+ static getParentElement(node) {
+ while (node !== null && node.nodeType !== Node.ELEMENT_NODE) {
+ node = node.parentNode;
+ }
+ return node;
+ }
+
+ static getElementWritingMode(element) {
+ if (element === null) {
+ return 'horizontal-tb';
+ }
+
+ const style = window.getComputedStyle(element);
+ const writingMode = style.writingMode;
+ return typeof writingMode === 'string' ? writingMode : 'horizontal-tb';
+ }
}
@@ -221,6 +249,10 @@ class TextSourceElement {
return new TextSourceElement(this.element, this.content);
}
+ cleanup() {
+ // NOP
+ }
+
text() {
return this.content;
}
@@ -267,6 +299,10 @@ class TextSourceElement {
return this.element.getBoundingClientRect();
}
+ getWritingMode() {
+ return 'horizontal-tb';
+ }
+
select() {
// NOP
}
diff --git a/ext/fg/js/util.js b/ext/fg/js/util.js
index 954b3988..7518beb5 100644
--- a/ext/fg/js/util.js
+++ b/ext/fg/js/util.js
@@ -27,6 +27,7 @@ function utilInvoke(action, params={}) {
return new Promise((resolve, reject) => {
try {
chrome.runtime.sendMessage({action, params}, (response) => {
+ utilCheckLastError(chrome.runtime.lastError);
if (response !== null && typeof response === 'object') {
if (response.error) {
reject(response.error);
@@ -43,3 +44,7 @@ function utilInvoke(action, params={}) {
}
});
}
+
+function utilCheckLastError(e) {
+ // NOP
+}