From 2a86d6609210a586ec32c48a99904c9b64744d04 Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Sun, 9 Aug 2020 21:07:11 -0400 Subject: DOM + DocumentUtil merge (#727) * Add DOM functions to DocumentUtil * Use DocumentUtil instead of DOM * Remove DOM * Move document-util.js into mixed --- ext/mixed/js/display.js | 9 +- ext/mixed/js/document-util.js | 506 ++++++++++++++++++++++++++++++++++++++++++ ext/mixed/js/dom.js | 145 ------------ ext/mixed/js/text-scanner.js | 16 +- 4 files changed, 518 insertions(+), 158 deletions(-) create mode 100644 ext/mixed/js/document-util.js delete mode 100644 ext/mixed/js/dom.js (limited to 'ext/mixed/js') diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 170b9d23..08ececc7 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -17,7 +17,6 @@ /* global * AudioSystem - * DOM * DisplayGenerator * DisplayHistory * DocumentUtil @@ -186,11 +185,11 @@ class Display extends EventDispatcher { } onKeyDown(e) { - const key = DOM.getKeyFromEvent(e); + const key = DocumentUtil.getKeyFromEvent(e); const handlers = this._hotkeys.get(key); if (typeof handlers === 'undefined') { return false; } - const eventModifiers = DOM.getActiveModifiers(e); + const eventModifiers = DocumentUtil.getActiveModifiers(e); for (const {modifiers, action} of handlers) { if (getSetDifference(modifiers, eventModifiers).size !== 0) { continue; } @@ -558,7 +557,7 @@ class Display extends EventDispatcher { } _onGlossaryMouseDown(e) { - if (DOM.isMouseButtonPressed(e, 'primary')) { + if (DocumentUtil.isMouseButtonPressed(e, 'primary')) { this._clickScanPrevent = false; } } @@ -568,7 +567,7 @@ class Display extends EventDispatcher { } _onGlossaryMouseUp(e) { - if (!this._clickScanPrevent && DOM.isMouseButtonPressed(e, 'primary')) { + if (!this._clickScanPrevent && DocumentUtil.isMouseButtonPressed(e, 'primary')) { try { this._onTermLookup(e); } catch (error) { diff --git a/ext/mixed/js/document-util.js b/ext/mixed/js/document-util.js new file mode 100644 index 00000000..ba39942d --- /dev/null +++ b/ext/mixed/js/document-util.js @@ -0,0 +1,506 @@ +/* + * Copyright (C) 2016-2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * DOMTextScanner + * TextSourceElement + * TextSourceRange + */ + +class DocumentUtil { + constructor() { + this._transparentColorPattern = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/; + } + + getRangeFromPoint(x, y, deepContentScan) { + const elements = this._getElementsFromPoint(x, y, deepContentScan); + let imposter = null; + let imposterContainer = null; + let imposterSourceElement = null; + if (elements.length > 0) { + const element = elements[0]; + switch (element.nodeName.toUpperCase()) { + case 'IMG': + case 'BUTTON': + return new TextSourceElement(element); + case 'INPUT': + imposterSourceElement = element; + [imposter, imposterContainer] = this._createImposter(element, false); + break; + case 'TEXTAREA': + imposterSourceElement = element; + [imposter, imposterContainer] = this._createImposter(element, true); + break; + } + } + + const range = this._caretRangeFromPointExt(x, y, deepContentScan ? elements : []); + if (range !== null) { + if (imposter !== null) { + this._setImposterStyle(imposterContainer.style, 'z-index', '-2147483646'); + this._setImposterStyle(imposter.style, 'pointer-events', 'none'); + } + return new TextSourceRange(range, '', imposterContainer, imposterSourceElement); + } else { + if (imposterContainer !== null) { + imposterContainer.parentNode.removeChild(imposterContainer); + } + return null; + } + } + + extractSentence(source, extent, layoutAwareScan) { + const quotesFwd = {'「': '」', '『': '』', "'": "'", '"': '"'}; + const quotesBwd = {'」': '「', '』': '『', "'": "'", '"': '"'}; + const terminators = '…。..??!!'; + + const sourceLocal = source.clone(); + const position = sourceLocal.setStartOffset(extent, layoutAwareScan); + sourceLocal.setEndOffset(extent * 2 - position, layoutAwareScan, true); + const content = sourceLocal.text(); + + let quoteStack = []; + + let startPos = 0; + for (let i = position; i >= startPos; --i) { + const c = content[i]; + + if (c === '\n') { + startPos = i + 1; + break; + } + + if (quoteStack.length === 0 && (terminators.includes(c) || c in quotesFwd)) { + startPos = i + 1; + break; + } + + if (quoteStack.length > 0 && c === quoteStack[0]) { + quoteStack.pop(); + } else if (c in quotesBwd) { + quoteStack.unshift(quotesBwd[c]); + } + } + + quoteStack = []; + + let endPos = content.length; + for (let i = position; i <= endPos; ++i) { + const c = content[i]; + + if (c === '\n') { + endPos = i + 1; + break; + } + + if (quoteStack.length === 0) { + if (terminators.includes(c)) { + endPos = i + 1; + break; + } else if (c in quotesBwd) { + endPos = i; + break; + } + } + + if (quoteStack.length > 0 && c === quoteStack[0]) { + quoteStack.pop(); + } else if (c in quotesFwd) { + quoteStack.unshift(quotesFwd[c]); + } + } + + const text = content.substring(startPos, endPos); + const padding = text.length - text.replace(/^\s+/, '').length; + + return { + text: text.trim(), + offset: position - startPos - padding + }; + } + + static isPointInRect(x, y, rect) { + return ( + x >= rect.left && x < rect.right && + y >= rect.top && y < rect.bottom + ); + } + + static isPointInAnyRect(x, y, rects) { + for (const rect of rects) { + if (this.isPointInRect(x, y, rect)) { + return true; + } + } + return false; + } + + static isPointInSelection(x, y, selection) { + for (let i = 0; i < selection.rangeCount; ++i) { + const range = selection.getRangeAt(i); + if (this.isPointInAnyRect(x, y, range.getClientRects())) { + return true; + } + } + return false; + } + + static isMouseButtonPressed(mouseEvent, button) { + const mouseEventButton = mouseEvent.button; + switch (button) { + case 'primary': return mouseEventButton === 0; + case 'secondary': return mouseEventButton === 2; + case 'auxiliary': return mouseEventButton === 1; + default: return false; + } + } + + static isMouseButtonDown(mouseEvent, button) { + const mouseEventButtons = mouseEvent.buttons; + switch (button) { + case 'primary': return (mouseEventButtons & 0x1) !== 0x0; + case 'secondary': return (mouseEventButtons & 0x2) !== 0x0; + case 'auxiliary': return (mouseEventButtons & 0x4) !== 0x0; + default: return false; + } + } + + static getActiveModifiers(event) { + const modifiers = new Set(); + if (event.altKey) { modifiers.add('alt'); } + if (event.ctrlKey) { modifiers.add('ctrl'); } + if (event.metaKey) { modifiers.add('meta'); } + if (event.shiftKey) { modifiers.add('shift'); } + return modifiers; + } + + static getKeyFromEvent(event) { + const key = event.key; + return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : ''); + } + + static addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection=null) { + const target = document; + const options = false; + const fullscreenEventNames = [ + 'fullscreenchange', + 'MSFullscreenChange', + 'mozfullscreenchange', + 'webkitfullscreenchange' + ]; + for (const eventName of fullscreenEventNames) { + if (eventListenerCollection === null) { + target.addEventListener(eventName, onFullscreenChanged, options); + } else { + eventListenerCollection.addEventListener(target, eventName, onFullscreenChanged, options); + } + } + } + + static getFullscreenElement() { + return ( + document.fullscreenElement || + document.msFullscreenElement || + document.mozFullScreenElement || + document.webkitFullscreenElement || + null + ); + } + + static getNodesInRange(range) { + const end = range.endContainer; + const nodes = []; + for (let node = range.startContainer; node !== null; node = this.getNextNode(node)) { + nodes.push(node); + if (node === end) { break; } + } + return nodes; + } + + static getNextNode(node) { + let next = node.firstChild; + if (next === null) { + while (true) { + next = node.nextSibling; + if (next !== null) { break; } + + next = node.parentNode; + if (next === null) { break; } + + node = next; + } + } + return next; + } + + static anyNodeMatchesSelector(nodes, selector) { + const ELEMENT_NODE = Node.ELEMENT_NODE; + for (let node of nodes) { + for (; node !== null; node = node.parentNode) { + if (node.nodeType !== ELEMENT_NODE) { continue; } + if (node.matches(selector)) { return true; } + break; + } + } + return false; + } + + // Private + + _setImposterStyle(style, propertyName, value) { + style.setProperty(propertyName, value, 'important'); + } + + _createImposter(element, isTextarea) { + const body = document.body; + if (body === null) { return [null, null]; } + + const elementStyle = window.getComputedStyle(element); + const elementRect = element.getBoundingClientRect(); + const documentRect = document.documentElement.getBoundingClientRect(); + let left = elementRect.left - documentRect.left; + let top = elementRect.top - documentRect.top; + + // Container + const container = document.createElement('div'); + const containerStyle = container.style; + this._setImposterStyle(containerStyle, 'all', 'initial'); + this._setImposterStyle(containerStyle, 'position', 'absolute'); + this._setImposterStyle(containerStyle, 'left', '0'); + this._setImposterStyle(containerStyle, 'top', '0'); + this._setImposterStyle(containerStyle, 'width', `${documentRect.width}px`); + this._setImposterStyle(containerStyle, 'height', `${documentRect.height}px`); + this._setImposterStyle(containerStyle, 'overflow', 'hidden'); + this._setImposterStyle(containerStyle, 'opacity', '0'); + this._setImposterStyle(containerStyle, 'pointer-events', 'none'); + this._setImposterStyle(containerStyle, 'z-index', '2147483646'); + + // Imposter + const imposter = document.createElement('div'); + const imposterStyle = imposter.style; + + let value = element.value; + if (value.endsWith('\n')) { value += '\n'; } + imposter.textContent = value; + + for (let i = 0, ii = elementStyle.length; i < ii; ++i) { + const property = elementStyle[i]; + this._setImposterStyle(imposterStyle, property, elementStyle.getPropertyValue(property)); + } + this._setImposterStyle(imposterStyle, 'position', 'absolute'); + this._setImposterStyle(imposterStyle, 'top', `${top}px`); + this._setImposterStyle(imposterStyle, 'left', `${left}px`); + this._setImposterStyle(imposterStyle, 'margin', '0'); + this._setImposterStyle(imposterStyle, 'pointer-events', 'auto'); + + if (isTextarea) { + if (elementStyle.overflow === 'visible') { + this._setImposterStyle(imposterStyle, 'overflow', 'auto'); + } + } else { + this._setImposterStyle(imposterStyle, 'overflow', 'hidden'); + this._setImposterStyle(imposterStyle, 'white-space', 'nowrap'); + this._setImposterStyle(imposterStyle, 'line-height', elementStyle.height); + } + + container.appendChild(imposter); + 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); + this._setImposterStyle(imposterStyle, 'width', `${width}px`); + this._setImposterStyle(imposterStyle, 'height', `${height}px`); + } + if (imposterRect.x !== elementRect.x || imposterRect.y !== elementRect.y) { + left += (elementRect.left - imposterRect.left); + top += (elementRect.top - imposterRect.top); + this._setImposterStyle(imposterStyle, 'left', `${left}px`); + this._setImposterStyle(imposterStyle, 'top', `${top}px`); + } + + imposter.scrollTop = element.scrollTop; + imposter.scrollLeft = element.scrollLeft; + + return [imposter, container]; + } + + _getElementsFromPoint(x, y, all) { + if (all) { + // document.elementsFromPoint can return duplicates which must be removed. + const elements = document.elementsFromPoint(x, y); + return elements.filter((e, i) => elements.indexOf(e) === i); + } + + const e = document.elementFromPoint(x, y); + return e !== null ? [e] : []; + } + + _isPointInRange(x, y, range) { + // Require a text node to start + if (range.startContainer.nodeType !== Node.TEXT_NODE) { + return false; + } + + // Scan forward + const nodePre = range.endContainer; + const offsetPre = range.endOffset; + try { + const {node, offset, content} = new DOMTextScanner(range.endContainer, range.endOffset, true, false).seek(1); + range.setEnd(node, offset); + + if (!this._isWhitespace(content) && DocumentUtil.isPointInAnyRect(x, y, range.getClientRects())) { + return true; + } + } finally { + range.setEnd(nodePre, offsetPre); + } + + // Scan backward + const {node, offset, content} = new DOMTextScanner(range.startContainer, range.startOffset, true, false).seek(-1); + range.setStart(node, offset); + + if (!this._isWhitespace(content) && DocumentUtil.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; + } + + _isWhitespace(string) { + return string.trim().length === 0; + } + + _caretRangeFromPoint(x, y) { + if (typeof document.caretRangeFromPoint === 'function') { + // Chrome, Edge + return document.caretRangeFromPoint(x, y); + } + + if (typeof document.caretPositionFromPoint === 'function') { + // Firefox + return this._caretPositionFromPoint(x, y); + } + + // No support + return null; + } + + _caretPositionFromPoint(x, y) { + const position = document.caretPositionFromPoint(x, y); + if (position === null) { + return null; + } + const node = position.offsetNode; + if (node === null) { + return null; + } + + const range = document.createRange(); + const offset = (node.nodeType === Node.TEXT_NODE ? position.offset : 0); + try { + range.setStart(node, offset); + range.setEnd(node, offset); + } catch (e) { + // Firefox throws new DOMException("The operation is insecure.") + // when trying to select a node from within a ShadowRoot. + return null; + } + return range; + } + + _caretRangeFromPointExt(x, y, elements) { + const modifications = []; + try { + let i = 0; + let startContinerPre = null; + while (true) { + const range = this._caretRangeFromPoint(x, y); + if (range === null) { + return null; + } + + const startContainer = range.startContainer; + if (startContinerPre !== startContainer) { + if (this._isPointInRange(x, y, range)) { + return range; + } + startContinerPre = startContainer; + } + + i = this._disableTransparentElement(elements, i, modifications); + if (i < 0) { + return null; + } + } + } finally { + if (modifications.length > 0) { + this._restoreElementStyleModifications(modifications); + } + } + } + + _disableTransparentElement(elements, i, modifications) { + while (true) { + if (i >= elements.length) { + return -1; + } + + const element = elements[i++]; + if (this._isElementTransparent(element)) { + const style = element.hasAttribute('style') ? element.getAttribute('style') : null; + modifications.push({element, style}); + element.style.setProperty('pointer-events', 'none', 'important'); + return i; + } + } + } + + _restoreElementStyleModifications(modifications) { + for (const {element, style} of modifications) { + if (style === null) { + element.removeAttribute('style'); + } else { + element.setAttribute('style', style); + } + } + } + + _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' && this._isColorTransparent(style.backgroundColor)) + ); + } + + _isColorTransparent(cssColor) { + return this._transparentColorPattern.test(cssColor); + } +} diff --git a/ext/mixed/js/dom.js b/ext/mixed/js/dom.js deleted file mode 100644 index 59fea9f6..00000000 --- a/ext/mixed/js/dom.js +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (C) 2019-2020 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - - -class DOM { - static isPointInRect(x, y, rect) { - return ( - x >= rect.left && x < rect.right && - y >= rect.top && y < rect.bottom - ); - } - - static isPointInAnyRect(x, y, rects) { - for (const rect of rects) { - if (DOM.isPointInRect(x, y, rect)) { - return true; - } - } - return false; - } - - static isPointInSelection(x, y, selection) { - for (let i = 0; i < selection.rangeCount; ++i) { - const range = selection.getRangeAt(i); - if (DOM.isPointInAnyRect(x, y, range.getClientRects())) { - return true; - } - } - return false; - } - - static isMouseButtonPressed(mouseEvent, button) { - const mouseEventButton = mouseEvent.button; - switch (button) { - case 'primary': return mouseEventButton === 0; - case 'secondary': return mouseEventButton === 2; - case 'auxiliary': return mouseEventButton === 1; - default: return false; - } - } - - static isMouseButtonDown(mouseEvent, button) { - const mouseEventButtons = mouseEvent.buttons; - switch (button) { - case 'primary': return (mouseEventButtons & 0x1) !== 0x0; - case 'secondary': return (mouseEventButtons & 0x2) !== 0x0; - case 'auxiliary': return (mouseEventButtons & 0x4) !== 0x0; - default: return false; - } - } - - static getActiveModifiers(event) { - const modifiers = new Set(); - if (event.altKey) { modifiers.add('alt'); } - if (event.ctrlKey) { modifiers.add('ctrl'); } - if (event.metaKey) { modifiers.add('meta'); } - if (event.shiftKey) { modifiers.add('shift'); } - return modifiers; - } - - static getKeyFromEvent(event) { - const key = event.key; - return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : ''); - } - - static addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection=null) { - const target = document; - const options = false; - const fullscreenEventNames = [ - 'fullscreenchange', - 'MSFullscreenChange', - 'mozfullscreenchange', - 'webkitfullscreenchange' - ]; - for (const eventName of fullscreenEventNames) { - if (eventListenerCollection === null) { - target.addEventListener(eventName, onFullscreenChanged, options); - } else { - eventListenerCollection.addEventListener(target, eventName, onFullscreenChanged, options); - } - } - } - - static getFullscreenElement() { - return ( - document.fullscreenElement || - document.msFullscreenElement || - document.mozFullScreenElement || - document.webkitFullscreenElement || - null - ); - } - - static getNodesInRange(range) { - const end = range.endContainer; - const nodes = []; - for (let node = range.startContainer; node !== null; node = DOM.getNextNode(node)) { - nodes.push(node); - if (node === end) { break; } - } - return nodes; - } - - static getNextNode(node) { - let next = node.firstChild; - if (next === null) { - while (true) { - next = node.nextSibling; - if (next !== null) { break; } - - next = node.parentNode; - if (next === null) { break; } - - node = next; - } - } - return next; - } - - static anyNodeMatchesSelector(nodes, selector) { - const ELEMENT_NODE = Node.ELEMENT_NODE; - for (let node of nodes) { - for (; node !== null; node = node.parentNode) { - if (node.nodeType !== ELEMENT_NODE) { continue; } - if (node.matches(selector)) { return true; } - break; - } - } - return false; - } -} diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index 923784b3..d0256b1e 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -16,7 +16,7 @@ */ /* global - * DOM + * DocumentUtil */ class TextScanner extends EventDispatcher { @@ -155,8 +155,8 @@ class TextScanner extends EventDispatcher { if (this._ignoreNodes !== null && clonedTextSource.range) { length = clonedTextSource.text().length; while (clonedTextSource.range && length > 0) { - const nodes = DOM.getNodesInRange(clonedTextSource.range); - if (!DOM.anyNodeMatchesSelector(nodes, this._ignoreNodes)) { + const nodes = DocumentUtil.getNodesInRange(clonedTextSource.range); + if (!DocumentUtil.anyNodeMatchesSelector(nodes, this._ignoreNodes)) { break; } --length; @@ -204,16 +204,16 @@ class TextScanner extends EventDispatcher { _onMouseMove(e) { this._scanTimerClear(); - if (this._pendingLookup || DOM.isMouseButtonDown(e, 'primary')) { + if (this._pendingLookup || DocumentUtil.isMouseButtonDown(e, 'primary')) { return; } - const modifiers = DOM.getActiveModifiers(e); + const modifiers = DocumentUtil.getActiveModifiers(e); this.trigger('activeModifiersChanged', {modifiers}); if (!( this._isScanningModifierPressed(this._modifier, e) || - (this._useMiddleMouse && DOM.isMouseButtonDown(e, 'auxiliary')) + (this._useMiddleMouse && DocumentUtil.isMouseButtonDown(e, 'auxiliary')) )) { return; } @@ -241,7 +241,7 @@ class TextScanner extends EventDispatcher { return false; } - if (DOM.isMouseButtonDown(e, 'primary')) { + if (DocumentUtil.isMouseButtonDown(e, 'primary')) { this._scanTimerClear(); this.clearSelection(false); } @@ -284,7 +284,7 @@ class TextScanner extends EventDispatcher { this._preventNextClick = false; const primaryTouch = e.changedTouches[0]; - if (DOM.isPointInSelection(primaryTouch.clientX, primaryTouch.clientY, window.getSelection())) { + if (DocumentUtil.isPointInSelection(primaryTouch.clientX, primaryTouch.clientY, window.getSelection())) { return; } -- cgit v1.2.3