diff options
Diffstat (limited to 'ext/js/dom')
-rw-r--r-- | ext/js/dom/document-focus-controller.js | 110 | ||||
-rw-r--r-- | ext/js/dom/document-util.js | 570 | ||||
-rw-r--r-- | ext/js/dom/dom-data-binder.js | 234 | ||||
-rw-r--r-- | ext/js/dom/html-template-collection.js | 78 | ||||
-rw-r--r-- | ext/js/dom/panel-element.js | 103 | ||||
-rw-r--r-- | ext/js/dom/popup-menu.js | 206 | ||||
-rw-r--r-- | ext/js/dom/selector-observer.js | 255 | ||||
-rw-r--r-- | ext/js/dom/window-scroll.js | 110 |
8 files changed, 1666 insertions, 0 deletions
diff --git a/ext/js/dom/document-focus-controller.js b/ext/js/dom/document-focus-controller.js new file mode 100644 index 00000000..649b5abe --- /dev/null +++ b/ext/js/dom/document-focus-controller.js @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2020-2021 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 <https://www.gnu.org/licenses/>. + */ + +/** + * This class is used to control the document focus when a non-body element contains the main scrollbar. + * Web browsers will not automatically focus a custom element with the scrollbar on load, which results in + * keyboard shortcuts (e.g. arrow keys) not controlling page scroll. Instead, this class will manually + * focus a dummy element inside the main content, which gives keyboard scroll focus to that element. + */ +class DocumentFocusController { + constructor() { + this._contentScrollFocusElement = document.querySelector('#content-scroll-focus'); + } + + prepare() { + window.addEventListener('focus', this._onWindowFocus.bind(this), false); + this._updateFocusedElement(false); + } + + blurElement(element) { + if (document.activeElement !== element) { return; } + element.blur(); + this._updateFocusedElement(false); + } + + // Private + + _onWindowFocus() { + this._updateFocusedElement(false); + } + + _updateFocusedElement(force) { + const target = this._contentScrollFocusElement; + if (target === null) { return; } + + const {activeElement} = document; + if ( + force || + activeElement === null || + activeElement === document.documentElement || + activeElement === document.body + ) { + // Get selection + const selection = window.getSelection(); + const selectionRanges1 = this._getSelectionRanges(selection); + + // Note: This function will cause any selected text to be deselected on Firefox. + target.focus({preventScroll: true}); + + // Restore selection + const selectionRanges2 = this._getSelectionRanges(selection); + if (!this._areRangesSame(selectionRanges1, selectionRanges2)) { + this._setSelectionRanges(selection, selectionRanges1); + } + } + } + + _getSelectionRanges(selection) { + const ranges = []; + for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { + ranges.push(selection.getRangeAt(i)); + } + return ranges; + } + + _setSelectionRanges(selection, ranges) { + selection.removeAllRanges(); + for (const range of ranges) { + selection.addRange(range); + } + } + + _areRangesSame(ranges1, ranges2) { + const ii = ranges1.length; + if (ii !== ranges2.length) { + return false; + } + + for (let i = 0; i < ii; ++i) { + const range1 = ranges1[i]; + const range2 = ranges2[i]; + try { + if ( + range1.compareBoundaryPoints(Range.START_TO_START, range2) !== 0 || + range1.compareBoundaryPoints(Range.END_TO_END, range2) !== 0 + ) { + return false; + } + } catch (e) { + return false; + } + } + + return true; + } +} diff --git a/ext/js/dom/document-util.js b/ext/js/dom/document-util.js new file mode 100644 index 00000000..513a0c05 --- /dev/null +++ b/ext/js/dom/document-util.js @@ -0,0 +1,570 @@ +/* + * Copyright (C) 2016-2021 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 <https://www.gnu.org/licenses/>. + */ + +/* 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; + } + } + + /** + * Extract a sentence from a document. + * @param source The text source object, either `TextSourceRange` or `TextSourceElement`. + * @param layoutAwareScan Whether or not layout-aware scan mode should be used. + * @param extent The length of the sentence to extract. + * @param terminatorMap A mapping of characters that terminate a sentence. + * Format: + * ```js + * new Map([ [character: string, [includeCharacterAtStart: boolean, includeCharacterAtEnd: boolean]], ... ]) + * ``` + * @param forwardQuoteMap A mapping of quote characters that delimit a sentence. + * Format: + * ```js + * new Map([ [character: string, [otherCharacter: string, includeCharacterAtStart: boolean]], ... ]) + * ``` + * @param backwardQuoteMap A mapping of quote characters that delimit a sentence, + * which is the inverse of forwardQuoteMap. + * Format: + * ```js + * new Map([ [character: string, [otherCharacter: string, includeCharacterAtEnd: boolean]], ... ]) + * ``` + * @returns The sentence and the offset to the original source: `{sentence: string, offset: integer}`. + */ + extractSentence(source, layoutAwareScan, extent, terminatorMap, forwardQuoteMap, backwardQuoteMap) { + // Scan text + source = source.clone(); + const startLength = source.setStartOffset(extent, layoutAwareScan); + const endLength = source.setEndOffset(extent * 2 - startLength, layoutAwareScan, true); + const text = source.text(); + const textLength = text.length; + const textEndAnchor = textLength - endLength; + let pos1 = startLength; + let pos2 = textEndAnchor; + + // Move backward + let quoteStack = []; + for (; pos1 > 0; --pos1) { + const c = text[pos1 - 1]; + if (c === '\n') { break; } + + if (quoteStack.length === 0) { + const terminatorInfo = terminatorMap.get(c); + if (typeof terminatorInfo !== 'undefined') { + if (terminatorInfo[0]) { --pos1; } + break; + } + } + + let quoteInfo = forwardQuoteMap.get(c); + if (typeof quoteInfo !== 'undefined') { + if (quoteStack.length === 0) { + if (quoteInfo[1]) { --pos1; } + break; + } else if (quoteStack[0] === c) { + quoteStack.pop(); + continue; + } + } + + quoteInfo = backwardQuoteMap.get(c); + if (typeof quoteInfo !== 'undefined') { + quoteStack.unshift(quoteInfo[0]); + } + } + + // Move forward + quoteStack = []; + for (; pos2 < textLength; ++pos2) { + const c = text[pos2]; + if (c === '\n') { break; } + + if (quoteStack.length === 0) { + const terminatorInfo = terminatorMap.get(c); + if (typeof terminatorInfo !== 'undefined') { + if (terminatorInfo[1]) { ++pos2; } + break; + } + } + + let quoteInfo = backwardQuoteMap.get(c); + if (typeof quoteInfo !== 'undefined') { + if (quoteStack.length === 0) { + if (quoteInfo[1]) { ++pos2; } + break; + } else if (quoteStack[0] === c) { + quoteStack.pop(); + continue; + } + } + + quoteInfo = forwardQuoteMap.get(c); + if (typeof quoteInfo !== 'undefined') { + quoteStack.unshift(quoteInfo[0]); + } + } + + // Trim whitespace + for (; pos1 < startLength && this._isWhitespace(text[pos1]); ++pos1) { /* NOP */ } + for (; pos2 > textEndAnchor && this._isWhitespace(text[pos2 - 1]); --pos2) { /* NOP */ } + + // Result + return { + text: text.substring(pos1, pos2), + offset: startLength - pos1 + }; + } + + 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 getActiveModifiers(event) { + const modifiers = []; + if (event.altKey) { modifiers.push('alt'); } + if (event.ctrlKey) { modifiers.push('ctrl'); } + if (event.metaKey) { modifiers.push('meta'); } + if (event.shiftKey) { modifiers.push('shift'); } + return modifiers; + } + + static getActiveModifiersAndButtons(event) { + const modifiers = this.getActiveModifiers(event); + this._getActiveButtons(event, modifiers); + return modifiers; + } + + static getActiveButtons(event) { + const buttons = []; + this._getActiveButtons(event, buttons); + return buttons; + } + + 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; + } + + static everyNodeMatchesSelector(nodes, selector) { + const ELEMENT_NODE = Node.ELEMENT_NODE; + for (let node of nodes) { + while (true) { + if (node === null) { return false; } + if (node.nodeType === ELEMENT_NODE && node.matches(selector)) { break; } + node = node.parentNode; + } + } + return true; + } + + static isMetaKeySupported(os, browser) { + return !(browser === 'firefox' || browser === 'firefox-mobile') || os === 'mac'; + } + + static _getActiveButtons(event, array) { + let {buttons} = event; + if (typeof buttons === 'number' && buttons > 0) { + for (let i = 0; i < 6; ++i) { + const buttonFlag = (1 << i); + if ((buttons & buttonFlag) !== 0) { + array.push(`mouse${i}`); + buttons &= ~buttonFlag; + if (buttons === 0) { break; } + } + } + } + } + + // 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/js/dom/dom-data-binder.js b/ext/js/dom/dom-data-binder.js new file mode 100644 index 00000000..292b2f67 --- /dev/null +++ b/ext/js/dom/dom-data-binder.js @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2020-2021 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 <https://www.gnu.org/licenses/>. + */ + +/* global + * SelectorObserver + * TaskAccumulator + */ + +class DOMDataBinder { + constructor({selector, ignoreSelectors=[], createElementMetadata, compareElementMetadata, getValues, setValues, onError=null}) { + this._selector = selector; + this._ignoreSelectors = ignoreSelectors; + this._createElementMetadata = createElementMetadata; + this._compareElementMetadata = compareElementMetadata; + this._getValues = getValues; + this._setValues = setValues; + this._onError = onError; + this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this)); + this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this)); + this._selectorObserver = new SelectorObserver({ + selector, + ignoreSelector: (ignoreSelectors.length > 0 ? ignoreSelectors.join(',') : null), + onAdded: this._createObserver.bind(this), + onRemoved: this._removeObserver.bind(this), + onChildrenUpdated: this._onObserverChildrenUpdated.bind(this), + isStale: this._isObserverStale.bind(this) + }); + } + + observe(element) { + this._selectorObserver.observe(element, true); + } + + disconnect() { + this._selectorObserver.disconnect(); + } + + async refresh() { + await this._updateTasks.enqueue(null, {all: true}); + } + + // Private + + async _onBulkUpdate(tasks) { + let all = false; + const targets = []; + for (const [observer, task] of tasks) { + if (observer === null) { + if (task.data.all) { + all = true; + break; + } + } else { + targets.push([observer, task]); + } + } + if (all) { + targets.length = 0; + for (const observer of this._selectorObserver.datas()) { + targets.push([observer, null]); + } + } + + const args = targets.map(([observer]) => ({ + element: observer.element, + metadata: observer.metadata + })); + const responses = await this._getValues(args); + this._applyValues(targets, responses, true); + } + + async _onBulkAssign(tasks) { + const targets = tasks; + const args = targets.map(([observer, task]) => ({ + element: observer.element, + metadata: observer.metadata, + value: task.data.value + })); + const responses = await this._setValues(args); + this._applyValues(targets, responses, false); + } + + _onElementChange(observer) { + const value = this._getElementValue(observer.element); + observer.value = value; + observer.hasValue = true; + this._assignTasks.enqueue(observer, {value}); + } + + _applyValues(targets, response, ignoreStale) { + if (!Array.isArray(response)) { return; } + + for (let i = 0, ii = targets.length; i < ii; ++i) { + const [observer, task] = targets[i]; + const {error, result} = response[i]; + const stale = (task !== null && task.stale); + + if (error) { + if (typeof this._onError === 'function') { + this._onError(error, stale, observer.element, observer.metadata); + } + continue; + } + + if (stale && !ignoreStale) { continue; } + + observer.value = result; + observer.hasValue = true; + this._setElementValue(observer.element, result); + } + } + + _createObserver(element) { + const metadata = this._createElementMetadata(element); + const nodeName = element.nodeName.toUpperCase(); + const observer = { + element, + type: (nodeName === 'INPUT' ? element.type : null), + value: null, + hasValue: false, + onChange: null, + metadata + }; + observer.onChange = this._onElementChange.bind(this, observer); + + element.addEventListener('change', observer.onChange, false); + + this._updateTasks.enqueue(observer); + + return observer; + } + + _removeObserver(element, observer) { + element.removeEventListener('change', observer.onChange, false); + observer.onChange = null; + } + + _onObserverChildrenUpdated(element, observer) { + if (observer.hasValue) { + this._setElementValue(element, observer.value); + } + } + + _isObserverStale(element, observer) { + const {type, metadata} = observer; + const nodeName = element.nodeName.toUpperCase(); + return !( + type === (nodeName === 'INPUT' ? element.type : null) && + this._compareElementMetadata(metadata, this._createElementMetadata(element)) + ); + } + + _setElementValue(element, value) { + switch (element.nodeName.toUpperCase()) { + case 'INPUT': + switch (element.type) { + case 'checkbox': + element.checked = value; + break; + case 'text': + case 'number': + element.value = value; + break; + } + break; + case 'TEXTAREA': + case 'SELECT': + element.value = value; + break; + } + + const event = new CustomEvent('settingChanged', {detail: {value}}); + element.dispatchEvent(event); + } + + _getElementValue(element) { + switch (element.nodeName.toUpperCase()) { + case 'INPUT': + switch (element.type) { + case 'checkbox': + return !!element.checked; + case 'text': + return `${element.value}`; + case 'number': + return DOMDataBinder.convertToNumber(element.value, element); + } + break; + case 'TEXTAREA': + case 'SELECT': + return element.value; + } + return null; + } + + // Utilities + + static convertToNumber(value, constraints) { + value = parseFloat(value); + if (!Number.isFinite(value)) { return 0; } + + let {min, max, step} = constraints; + min = DOMDataBinder.convertToNumberOrNull(min); + max = DOMDataBinder.convertToNumberOrNull(max); + step = DOMDataBinder.convertToNumberOrNull(step); + if (typeof min === 'number') { value = Math.max(value, min); } + if (typeof max === 'number') { value = Math.min(value, max); } + if (typeof step === 'number' && step !== 0) { value = Math.round(value / step) * step; } + return value; + } + + static convertToNumberOrNull(value) { + if (typeof value !== 'number') { + if (typeof value !== 'string' || value.length === 0) { + return null; + } + value = parseFloat(value); + } + return !Number.isNaN(value) ? value : null; + } +} diff --git a/ext/js/dom/html-template-collection.js b/ext/js/dom/html-template-collection.js new file mode 100644 index 00000000..52d5f3b0 --- /dev/null +++ b/ext/js/dom/html-template-collection.js @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2020-2021 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 <http://www.gnu.org/licenses/>. + */ + +class HtmlTemplateCollection { + constructor(source) { + this._templates = new Map(); + + const sourceNode = ( + typeof source === 'string' ? + new DOMParser().parseFromString(source, 'text/html') : + source + ); + + const pattern = /^([\w\W]+)-template$/; + for (const template of sourceNode.querySelectorAll('template')) { + const match = pattern.exec(template.id); + if (match === null) { continue; } + this._prepareTemplate(template); + this._templates.set(match[1], template); + } + } + + instantiate(name) { + const template = this._templates.get(name); + return document.importNode(template.content.firstChild, true); + } + + instantiateFragment(name) { + const template = this._templates.get(name); + return document.importNode(template.content, true); + } + + getAllTemplates() { + return this._templates.values(); + } + + // Private + + _prepareTemplate(template) { + if (template.dataset.removeWhitespaceText === 'true') { + this._removeWhitespaceText(template); + } + } + + _removeWhitespaceText(template) { + const {content} = template; + const {TEXT_NODE} = Node; + const iterator = document.createNodeIterator(content, NodeFilter.SHOW_TEXT); + const removeNodes = []; + while (true) { + const node = iterator.nextNode(); + if (node === null) { break; } + if (node.nodeType === TEXT_NODE && node.nodeValue.trim().length === 0) { + removeNodes.push(node); + } + } + for (const node of removeNodes) { + const {parentNode} = node; + if (parentNode !== null) { + parentNode.removeChild(node); + } + } + } +} diff --git a/ext/js/dom/panel-element.js b/ext/js/dom/panel-element.js new file mode 100644 index 00000000..1ef61d6f --- /dev/null +++ b/ext/js/dom/panel-element.js @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2020-2021 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 <https://www.gnu.org/licenses/>. + */ + +class PanelElement extends EventDispatcher { + constructor({node, closingAnimationDuration}) { + super(); + this._node = node; + this._closingAnimationDuration = closingAnimationDuration; + this._hiddenAnimatingClass = 'hidden-animating'; + this._mutationObserver = null; + this._visible = false; + this._closeTimer = null; + } + + get node() { + return this._node; + } + + isVisible() { + return !this._node.hidden; + } + + setVisible(value, animate=true) { + value = !!value; + if (this.isVisible() === value) { return; } + + if (this._closeTimer !== null) { + clearTimeout(this._closeTimer); + this._completeClose(true); + } + + const node = this._node; + const {classList} = node; + if (value) { + if (animate) { classList.add(this._hiddenAnimatingClass); } + getComputedStyle(node).getPropertyValue('display'); // Force update of CSS display property, allowing animation + classList.remove(this._hiddenAnimatingClass); + node.hidden = false; + node.focus(); + } else { + if (animate) { classList.add(this._hiddenAnimatingClass); } + node.hidden = true; + if (animate) { + this._closeTimer = setTimeout(() => this._completeClose(false), this._closingAnimationDuration); + } + } + } + + on(eventName, callback) { + if (eventName === 'visibilityChanged') { + if (this._mutationObserver === null) { + this._visible = this.isVisible(); + this._mutationObserver = new MutationObserver(this._onMutation.bind(this)); + this._mutationObserver.observe(this._node, { + attributes: true, + attributeFilter: ['hidden'], + attributeOldValue: true + }); + } + } + return super.on(eventName, callback); + } + + off(eventName, callback) { + const result = super.off(eventName, callback); + if (eventName === 'visibilityChanged' && !this.hasListeners(eventName)) { + if (this._mutationObserver !== null) { + this._mutationObserver.disconnect(); + this._mutationObserver = null; + } + } + return result; + } + + // Private + + _onMutation() { + const visible = this.isVisible(); + if (this._visible === visible) { return; } + this._visible = visible; + this.trigger('visibilityChanged', {visible}); + } + + _completeClose(reopening) { + this._closeTimer = null; + this._node.classList.remove(this._hiddenAnimatingClass); + this.trigger('closeCompleted', {reopening}); + } +} diff --git a/ext/js/dom/popup-menu.js b/ext/js/dom/popup-menu.js new file mode 100644 index 00000000..9ad4e260 --- /dev/null +++ b/ext/js/dom/popup-menu.js @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2020-2021 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 <https://www.gnu.org/licenses/>. + */ + +class PopupMenu extends EventDispatcher { + constructor(sourceElement, containerNode) { + super(); + this._sourceElement = sourceElement; + this._containerNode = containerNode; + this._node = containerNode.querySelector('.popup-menu'); + this._bodyNode = containerNode.querySelector('.popup-menu-body'); + this._isClosed = false; + this._eventListeners = new EventListenerCollection(); + } + + get sourceElement() { + return this._sourceElement; + } + + get containerNode() { + return this._containerNode; + } + + get node() { + return this._node; + } + + get bodyNode() { + return this._bodyNode; + } + + get isClosed() { + return this._isClosed; + } + + prepare() { + const items = this._bodyNode.querySelectorAll('.popup-menu-item'); + this._setPosition(); + this._containerNode.focus(); + + this._eventListeners.addEventListener(window, 'resize', this._onWindowResize.bind(this), false); + this._eventListeners.addEventListener(this._containerNode, 'click', this._onMenuContainerClick.bind(this), false); + + const onMenuItemClick = this._onMenuItemClick.bind(this); + for (const item of items) { + this._eventListeners.addEventListener(item, 'click', onMenuItemClick, false); + } + + PopupMenu.openMenus.add(this); + + this._sourceElement.dispatchEvent(new CustomEvent('menuOpen', { + bubbles: false, + cancelable: false, + detail: {menu: this} + })); + } + + close(cancelable=true) { + return this._close(null, 'close', cancelable); + } + + // Private + + _onMenuContainerClick(e) { + if (e.currentTarget !== e.target) { return; } + e.stopPropagation(); + e.preventDefault(); + this._close(null, 'outside', true); + } + + _onMenuItemClick(e) { + const item = e.currentTarget; + if (item.disabled) { return; } + e.stopPropagation(); + e.preventDefault(); + this._close(item, 'item', true); + } + + _onWindowResize() { + this._close(null, 'resize', true); + } + + _setPosition() { + // Get flags + let horizontal = 1; + let vertical = 1; + let horizontalCover = 1; + let verticalCover = 1; + const positionInfo = this._sourceElement.dataset.menuPosition; + if (typeof positionInfo === 'string') { + const positionInfoSet = new Set(positionInfo.split(' ')); + + if (positionInfoSet.has('left')) { + horizontal = -1; + } else if (positionInfoSet.has('right')) { + horizontal = 1; + } else if (positionInfoSet.has('h-center')) { + horizontal = 0; + } + + if (positionInfoSet.has('above')) { + vertical = -1; + } else if (positionInfoSet.has('below')) { + vertical = 1; + } else if (positionInfoSet.has('v-center')) { + vertical = 0; + } + + if (positionInfoSet.has('cover')) { + horizontalCover = 1; + verticalCover = 1; + } else if (positionInfoSet.has('no-cover')) { + horizontalCover = -1; + verticalCover = -1; + } + + if (positionInfoSet.has('h-cover')) { + horizontalCover = 1; + } else if (positionInfoSet.has('no-h-cover')) { + horizontalCover = -1; + } + + if (positionInfoSet.has('v-cover')) { + verticalCover = 1; + } else if (positionInfoSet.has('no-v-cover')) { + verticalCover = -1; + } + } + + // Position + const menu = this._node; + const fullRect = this._containerNode.getBoundingClientRect(); + const sourceRect = this._sourceElement.getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + let top = menuRect.top; + let bottom = menuRect.bottom; + if (verticalCover === 1) { + const bodyRect = this._bodyNode.getBoundingClientRect(); + top = bodyRect.top; + bottom = bodyRect.bottom; + } + + let x = ( + sourceRect.left + + sourceRect.width * ((-horizontal * horizontalCover + 1) * 0.5) + + menuRect.width * ((-horizontal + 1) * -0.5) + ); + let y = ( + sourceRect.top + + (menuRect.top - top) + + sourceRect.height * ((-vertical * verticalCover + 1) * 0.5) + + (bottom - top) * ((-vertical + 1) * -0.5) + ); + + x = Math.max(0.0, Math.min(fullRect.width - menuRect.width, x)); + y = Math.max(0.0, Math.min(fullRect.height - menuRect.height, y)); + + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + } + + _close(item, cause, cancelable) { + if (this._isClosed) { return true; } + const action = (item !== null ? item.dataset.menuAction : null); + + const detail = { + menu: this, + item, + action, + cause + }; + const result = this._sourceElement.dispatchEvent(new CustomEvent('menuClose', {bubbles: false, cancelable, detail})); + if (cancelable && !result) { return false; } + + PopupMenu.openMenus.delete(this); + + this._isClosed = true; + this._eventListeners.removeAllEventListeners(); + if (this._containerNode.parentNode !== null) { + this._containerNode.parentNode.removeChild(this._containerNode); + } + + this.trigger('close', detail); + return true; + } +} + +Object.defineProperty(PopupMenu, 'openMenus', { + configurable: false, + enumerable: true, + writable: false, + value: new Set() +}); diff --git a/ext/js/dom/selector-observer.js b/ext/js/dom/selector-observer.js new file mode 100644 index 00000000..2f3fa49e --- /dev/null +++ b/ext/js/dom/selector-observer.js @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2020-2021 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 <https://www.gnu.org/licenses/>. + */ + +/** + * Class which is used to observe elements matching a selector in specific element. + */ +class SelectorObserver { + /** + * Creates a new instance. + * @param selector A string CSS selector used to find elements. + * @param ignoreSelector A string CSS selector used to filter elements, or null for no filtering. + * @param onAdded A function which is invoked for each element that is added that matches the selector. + * The signature is (element) => data. + * @param onRemoved A function which is invoked for each element that is removed, or null. + * The signature is (element, data) => void. + * @param onChildrenUpdated A function which is invoked for each element which has its children updated, or null. + * The signature is (element, data) => void. + * @param isStale A function which checks if the data is stale for a given element, or null. + * If the element is stale, it will be removed and potentially re-added. + * The signature is (element, data) => bool. + */ + constructor({selector, ignoreSelector=null, onAdded=null, onRemoved=null, onChildrenUpdated=null, isStale=null}) { + this._selector = selector; + this._ignoreSelector = ignoreSelector; + this._onAdded = onAdded; + this._onRemoved = onRemoved; + this._onChildrenUpdated = onChildrenUpdated; + this._isStale = isStale; + this._observingElement = null; + this._mutationObserver = new MutationObserver(this._onMutation.bind(this)); + this._elementMap = new Map(); // Map([element => observer]...) + this._elementAncestorMap = new Map(); // Map([element => Set([observer]...)]...) + this._isObserving = false; + } + + /** + * Returns whether or not an element is currently being observed. + * @returns True if an element is being observed, false otherwise. + */ + get isObserving() { + return this._observingElement !== null; + } + + /** + * Starts DOM mutation observing the target element. + * @param element The element to observe changes in. + * @param attributes A boolean for whether or not attribute changes should be observed. + * @throws An error if element is null. + * @throws An error if an element is already being observed. + */ + observe(element, attributes=false) { + if (element === null) { + throw new Error('Invalid element'); + } + if (this.isObserving) { + throw new Error('Instance is already observing an element'); + } + + this._observingElement = element; + this._mutationObserver.observe(element, { + attributes: !!attributes, + childList: true, + subtree: true + }); + + this._onMutation([{ + type: 'childList', + target: element.parentNode, + addedNodes: [element], + removedNodes: [] + }]); + } + + /** + * Stops observing the target element. + */ + disconnect() { + if (!this.isObserving) { return; } + + this._mutationObserver.disconnect(); + this._observingElement = null; + + for (const observer of this._elementMap.values()) { + this._removeObserver(observer); + } + } + + /** + * Returns an iterable list of [element, data] pairs. + * @yields A sequence of [element, data] pairs. + */ + *entries() { + for (const [element, {data}] of this._elementMap) { + yield [element, data]; + } + } + + /** + * Returns an iterable list of data for every element. + * @yields A sequence of data values. + */ + *datas() { + for (const {data} of this._elementMap.values()) { + yield data; + } + } + + // Private + + _onMutation(mutationList) { + for (const mutation of mutationList) { + switch (mutation.type) { + case 'childList': + this._onChildListMutation(mutation); + break; + case 'attributes': + this._onAttributeMutation(mutation); + break; + } + } + } + + _onChildListMutation({addedNodes, removedNodes, target}) { + const selector = this._selector; + const ELEMENT_NODE = Node.ELEMENT_NODE; + + for (const node of removedNodes) { + const observers = this._elementAncestorMap.get(node); + if (typeof observers === 'undefined') { continue; } + for (const observer of observers) { + this._removeObserver(observer); + } + } + + for (const node of addedNodes) { + if (node.nodeType !== ELEMENT_NODE) { continue; } + if (node.matches(selector)) { + this._createObserver(node); + } + for (const childNode of node.querySelectorAll(selector)) { + this._createObserver(childNode); + } + } + + if ( + this._onChildrenUpdated !== null && + (addedNodes.length !== 0 || addedNodes.length !== 0) + ) { + for (let node = target; node !== null; node = node.parentNode) { + const observer = this._elementMap.get(node); + if (typeof observer !== 'undefined') { + this._onObserverChildrenUpdated(observer); + } + } + } + } + + _onAttributeMutation({target}) { + const selector = this._selector; + const observers = this._elementAncestorMap.get(target); + if (typeof observers !== 'undefined') { + for (const observer of observers) { + const element = observer.element; + if ( + !element.matches(selector) || + this._shouldIgnoreElement(element) || + this._isObserverStale(observer) + ) { + this._removeObserver(observer); + } + } + } + + if (target.matches(selector)) { + this._createObserver(target); + } + } + + _createObserver(element) { + if (this._elementMap.has(element) || this._shouldIgnoreElement(element) || this._onAdded === null) { return; } + + const data = this._onAdded(element); + const ancestors = this._getAncestors(element); + const observer = {element, ancestors, data}; + + this._elementMap.set(element, observer); + + for (const ancestor of ancestors) { + let observers = this._elementAncestorMap.get(ancestor); + if (typeof observers === 'undefined') { + observers = new Set(); + this._elementAncestorMap.set(ancestor, observers); + } + observers.add(observer); + } + } + + _removeObserver(observer) { + const {element, ancestors, data} = observer; + + this._elementMap.delete(element); + + for (const ancestor of ancestors) { + const observers = this._elementAncestorMap.get(ancestor); + if (typeof observers === 'undefined') { continue; } + + observers.delete(observer); + if (observers.size === 0) { + this._elementAncestorMap.delete(ancestor); + } + } + + if (this._onRemoved !== null) { + this._onRemoved(element, data); + } + } + + _onObserverChildrenUpdated(observer) { + this._onChildrenUpdated(observer.element, observer.data); + } + + _isObserverStale(observer) { + return (this._isStale !== null && this._isStale(observer.element, observer.data)); + } + + _shouldIgnoreElement(element) { + return (this._ignoreSelector !== null && element.matches(this._ignoreSelector)); + } + + _getAncestors(node) { + const root = this._observingElement; + const results = []; + while (true) { + results.push(node); + if (node === root) { break; } + node = node.parentNode; + if (node === null) { break; } + } + return results; + } +} diff --git a/ext/js/dom/window-scroll.js b/ext/js/dom/window-scroll.js new file mode 100644 index 00000000..33577795 --- /dev/null +++ b/ext/js/dom/window-scroll.js @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2019-2021 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 <https://www.gnu.org/licenses/>. + */ + +class WindowScroll { + constructor(node) { + this._node = node; + this._animationRequestId = null; + this._animationStartTime = 0; + this._animationStartX = 0; + this._animationStartY = 0; + this._animationEndTime = 0; + this._animationEndX = 0; + this._animationEndY = 0; + this._requestAnimationFrameCallback = this._onAnimationFrame.bind(this); + } + + get x() { + return this._node !== null ? this._node.scrollLeft : window.scrollX || window.pageXOffset; + } + + get y() { + return this._node !== null ? this._node.scrollTop : window.scrollY || window.pageYOffset; + } + + toY(y) { + this.to(this.x, y); + } + + toX(x) { + this.to(x, this.y); + } + + to(x, y) { + this.stop(); + this._scroll(x, y); + } + + animate(x, y, time) { + this._animationStartX = this.x; + this._animationStartY = this.y; + this._animationStartTime = window.performance.now(); + this._animationEndX = x; + this._animationEndY = y; + this._animationEndTime = this._animationStartTime + time; + this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback); + } + + stop() { + if (this._animationRequestId === null) { + return; + } + + window.cancelAnimationFrame(this._animationRequestId); + this._animationRequestId = null; + } + + // Private + + _onAnimationFrame(time) { + if (time >= this._animationEndTime) { + this._scroll(this._animationEndX, this._animationEndY); + this._animationRequestId = null; + return; + } + + const t = this._easeInOutCubic((time - this._animationStartTime) / (this._animationEndTime - this._animationStartTime)); + this._scroll( + this._lerp(this._animationStartX, this._animationEndX, t), + this._lerp(this._animationStartY, this._animationEndY, t) + ); + + this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback); + } + + _easeInOutCubic(t) { + if (t < 0.5) { + return (4.0 * t * t * t); + } else { + t = 1.0 - t; + return 1.0 - (4.0 * t * t * t); + } + } + + _lerp(start, end, percent) { + return (end - start) * percent + start; + } + + _scroll(x, y) { + if (this._node !== null) { + this._node.scrollLeft = x; + this._node.scrollTop = y; + } else { + window.scroll(x, y); + } + } +} |