diff options
Diffstat (limited to 'ext/fg/js/popup.js')
-rw-r--r-- | ext/fg/js/popup.js | 486 |
1 files changed, 297 insertions, 189 deletions
diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index 42f08afa..b7d4b57e 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -16,8 +16,9 @@ */ /* global - * apiGetMessageToken - * apiInjectStylesheet + * DOM + * apiOptionsGet + * dynamicLoader */ class Popup { @@ -29,24 +30,24 @@ class Popup { this._child = null; this._childrenSupported = true; this._injectPromise = null; + this._injectPromiseComplete = false; this._visible = false; this._visibleOverride = null; this._options = null; + this._optionsContext = null; this._contentScale = 1.0; - this._containerSizeContentScale = null; this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); - this._messageToken = null; + this._previousOptionsContextSource = null; - this._container = document.createElement('iframe'); - this._container.className = 'yomichan-float'; - this._container.addEventListener('mousedown', (e) => e.stopPropagation()); - this._container.addEventListener('scroll', (e) => e.stopPropagation()); - this._container.style.width = '0px'; - this._container.style.height = '0px'; + this._frameSizeContentScale = null; + this._frameSecret = null; + this._frameToken = null; + this._frame = document.createElement('iframe'); + this._frame.className = 'yomichan-float'; + this._frame.style.width = '0'; + this._frame.style.height = '0'; this._fullscreenEventListeners = new EventListenerCollection(); - - this._updateVisibility(); } // Public properties @@ -71,19 +72,27 @@ class Popup { return this._frameId; } - get url() { - return window.location.href; - } - // Public functions + prepare() { + this._updateVisibility(); + this._frame.addEventListener('mousedown', (e) => e.stopPropagation()); + this._frame.addEventListener('scroll', (e) => e.stopPropagation()); + this._frame.addEventListener('load', this._onFrameLoad.bind(this)); + } + isProxy() { return false; } - async setOptions(options) { - this._options = options; + async setOptionsContext(optionsContext, source) { + this._optionsContext = optionsContext; + this._previousOptionsContextSource = source; + + this._options = await apiOptionsGet(optionsContext); this.updateTheme(); + + this._invokeApi('setOptionsContext', {optionsContext}); } hide(changeFocus) { @@ -111,7 +120,7 @@ class Popup { async containsPoint(x, y) { for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup._child) { - const rect = popup._container.getBoundingClientRect(); + const rect = popup._frame.getBoundingClientRect(); if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) { return true; } @@ -119,14 +128,20 @@ class Popup { return false; } - async showContent(elementRect, writingMode, type=null, details=null) { + async showContent(elementRect, writingMode, type, details, context) { if (this._options === null) { throw new Error('Options not assigned'); } + + const {optionsContext, source} = context; + if (source !== this._previousOptionsContextSource) { + await this.setOptionsContext(optionsContext, source); + } + await this._show(elementRect, writingMode); if (type === null) { return; } this._invokeApi('setContent', {type, details}); } - async setCustomCss(css) { + setCustomCss(css) { this._invokeApi('setCustomCss', {css}); } @@ -160,82 +175,218 @@ class Popup { } updateTheme() { - this._container.dataset.yomichanTheme = this._options.general.popupOuterTheme; - this._container.dataset.yomichanSiteColor = this._getSiteColor(); + this._frame.dataset.yomichanTheme = this._options.general.popupOuterTheme; + this._frame.dataset.yomichanSiteColor = this._getSiteColor(); } async setCustomOuterCss(css, useWebExtensionApi) { - return await Popup._injectStylesheet( - 'yomichan-popup-outer-user-stylesheet', - 'code', - css, - useWebExtensionApi - ); + return await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi); } setChildrenSupported(value) { this._childrenSupported = value; } - getContainer() { - return this._container; + getFrame() { + return this._frame; } - getContainerRect() { - return this._container.getBoundingClientRect(); + getFrameRect() { + return this._frame.getBoundingClientRect(); } // Private functions _inject() { - if (this._injectPromise === null) { - this._injectPromise = this._createInjectPromise(); + let injectPromise = this._injectPromise; + if (injectPromise === null) { + injectPromise = this._createInjectPromise(); + this._injectPromise = injectPromise; + injectPromise.then( + () => { + if (injectPromise !== this._injectPromise) { return; } + this._injectPromiseComplete = true; + }, + () => { this._resetFrame(); } + ); } - return this._injectPromise; + return injectPromise; + } + + _initializeFrame(frame, targetOrigin, frameId, setupFrame, timeout=10000) { + return new Promise((resolve, reject) => { + const tokenMap = new Map(); + let timer = null; + let frameLoadedResolve = null; + let frameLoadedReject = null; + const frameLoaded = new Promise((resolve2, reject2) => { + frameLoadedResolve = resolve2; + frameLoadedReject = reject2; + }); + + const postMessage = (action, params) => { + const contentWindow = frame.contentWindow; + if (contentWindow === null) { throw new Error('Frame missing content window'); } + + let validOrigin = true; + try { + validOrigin = (contentWindow.location.origin === targetOrigin); + } catch (e) { + // NOP + } + if (!validOrigin) { throw new Error('Unexpected frame origin'); } + + contentWindow.postMessage({action, params}, targetOrigin); + }; + + const onMessage = (message) => { + onMessageInner(message); + return false; + }; + + const onMessageInner = async (message) => { + try { + if (!isObject(message)) { return; } + const {action, params} = message; + if (!isObject(params)) { return; } + await frameLoaded; + if (timer === null) { return; } // Done + + switch (action) { + case 'popupPrepared': + { + const {secret} = params; + const token = yomichan.generateId(16); + tokenMap.set(secret, token); + postMessage('initialize', {secret, token, frameId}); + } + break; + case 'popupInitialized': + { + const {secret, token} = params; + const token2 = tokenMap.get(secret); + if (typeof token2 !== 'undefined' && token === token2) { + cleanup(); + resolve({secret, token}); + } + } + break; + } + } catch (e) { + cleanup(); + reject(e); + } + }; + + const onLoad = () => { + if (frameLoadedResolve === null) { + cleanup(); + reject(new Error('Unexpected load event')); + return; + } + + if (Popup.isFrameAboutBlank(frame)) { + return; + } + + frameLoadedResolve(); + frameLoadedResolve = null; + frameLoadedReject = null; + }; + + const cleanup = () => { + if (timer === null) { return; } // Done + clearTimeout(timer); + timer = null; + + frameLoadedResolve = null; + if (frameLoadedReject !== null) { + frameLoadedReject(new Error('Terminated')); + frameLoadedReject = null; + } + + chrome.runtime.onMessage.removeListener(onMessage); + frame.removeEventListener('load', onLoad); + }; + + // Start + timer = setTimeout(() => { + cleanup(); + reject(new Error('Timeout')); + }, timeout); + + chrome.runtime.onMessage.addListener(onMessage); + frame.addEventListener('load', onLoad); + + // Prevent unhandled rejections + frameLoaded.catch(() => {}); // NOP + + setupFrame(frame); + }); } async _createInjectPromise() { - if (this._messageToken === null) { - this._messageToken = await apiGetMessageToken(); - } + this._injectStyles(); + + const {secret, token} = await this._initializeFrame(this._frame, this._targetOrigin, this._frameId, (frame) => { + frame.removeAttribute('src'); + frame.removeAttribute('srcdoc'); + this._observeFullscreen(true); + this._onFullscreenChanged(); + frame.contentDocument.location.href = chrome.runtime.getURL('/fg/float.html'); + }); + this._frameSecret = secret; + this._frameToken = token; + // Configure + const messageId = yomichan.generateId(16); const popupPreparedPromise = yomichan.getTemporaryListenerResult( chrome.runtime.onMessage, - ({action, params}, {resolve}) => { + (message, {resolve}) => { if ( - action === 'popupPrepareCompleted' && - isObject(params) && - params.targetPopupId === this._id + isObject(message) && + message.action === 'popupConfigured' && + isObject(message.params) && + message.params.messageId === messageId ) { resolve(); } } ); - - const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null); - this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html')); - this._container.addEventListener('load', () => { - this._invokeApi('prepare', { - popupInfo: { - id: this._id, - depth: this._depth, - parentFrameId - }, - url: this.url, - childrenSupported: this._childrenSupported, - scale: this._contentScale - }); + this._invokeApi('configure', { + messageId, + frameId: this._frameId, + popupId: this._id, + optionsContext: this._optionsContext, + childrenSupported: this._childrenSupported, + scale: this._contentScale }); - this._observeFullscreen(true); - this._onFullscreenChanged(); - this._injectStyles(); return popupPreparedPromise; } + _onFrameLoad() { + if (!this._injectPromiseComplete) { return; } + this._resetFrame(); + } + + _resetFrame() { + const parent = this._frame.parentNode; + if (parent !== null) { + parent.removeChild(this._frame); + } + this._frame.removeAttribute('src'); + this._frame.removeAttribute('srcdoc'); + + this._frameSecret = null; + this._frameToken = null; + this._injectPromise = null; + this._injectPromiseComplete = false; + } + async _injectStyles() { try { - await Popup._injectStylesheet('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); + await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); } catch (e) { // NOP } @@ -271,9 +422,9 @@ class Popup { } _onFullscreenChanged() { - const parent = (Popup._getFullscreenElement() || document.body || null); - if (parent !== null && this._container.parentNode !== parent) { - parent.appendChild(this._container); + const parent = this._getFrameParentElement(); + if (parent !== null && this._frame.parentNode !== parent) { + parent.appendChild(this._frame); } } @@ -281,31 +432,31 @@ class Popup { await this._inject(); const optionsGeneral = this._options.general; - const container = this._container; - const containerRect = container.getBoundingClientRect(); - const getPosition = ( - writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ? - Popup._getPositionForHorizontalText : - Popup._getPositionForVerticalText - ); + const frame = this._frame; + const frameRect = frame.getBoundingClientRect(); - const viewport = Popup._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport); + const viewport = this._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport); const scale = this._contentScale; - const scaleRatio = this._containerSizeContentScale === null ? 1.0 : scale / this._containerSizeContentScale; - this._containerSizeContentScale = scale; - let [x, y, width, height, below] = getPosition( + const scaleRatio = this._frameSizeContentScale === null ? 1.0 : scale / this._frameSizeContentScale; + this._frameSizeContentScale = scale; + const getPositionArgs = [ elementRect, - Math.max(containerRect.width * scaleRatio, optionsGeneral.popupWidth * scale), - Math.max(containerRect.height * scaleRatio, optionsGeneral.popupHeight * scale), + Math.max(frameRect.width * scaleRatio, optionsGeneral.popupWidth * scale), + Math.max(frameRect.height * scaleRatio, optionsGeneral.popupHeight * scale), viewport, scale, optionsGeneral, writingMode + ]; + let [x, y, width, height, below] = ( + writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ? + this._getPositionForHorizontalText(...getPositionArgs) : + this._getPositionForVerticalText(...getPositionArgs) ); const fullWidth = (optionsGeneral.popupDisplayMode === 'full-width'); - container.classList.toggle('yomichan-float-full-width', fullWidth); - container.classList.toggle('yomichan-float-above', !below); + frame.classList.toggle('yomichan-float-full-width', fullWidth); + frame.classList.toggle('yomichan-float-above', !below); if (optionsGeneral.popupDisplayMode === 'full-width') { x = viewport.left; @@ -313,10 +464,10 @@ class Popup { width = viewport.right - viewport.left; } - container.style.left = `${x}px`; - container.style.top = `${y}px`; - container.style.width = `${width}px`; - container.style.height = `${height}px`; + frame.style.left = `${x}px`; + frame.style.top = `${y}px`; + frame.style.width = `${width}px`; + frame.style.height = `${height}px`; this._setVisible(true); if (this._child !== null) { @@ -330,20 +481,20 @@ class Popup { } _updateVisibility() { - this._container.style.setProperty('visibility', this.isVisibleSync() ? 'visible' : 'hidden', 'important'); + this._frame.style.setProperty('visibility', this.isVisibleSync() ? 'visible' : 'hidden', 'important'); } _focusParent() { if (this._parent !== null) { // Chrome doesn't like focusing iframe without contentWindow. - const contentWindow = this._parent._container.contentWindow; + const contentWindow = this._parent.getFrame().contentWindow; if (contentWindow !== null) { contentWindow.focus(); } } else { // Firefox doesn't like focusing window without first blurring the iframe. - // this.container.contentWindow.blur() doesn't work on Firefox for some reason. - this._container.blur(); + // this._frame.contentWindow.blur() doesn't work on Firefox for some reason. + this._frame.blur(); // This is needed for Chrome. window.focus(); } @@ -351,36 +502,52 @@ class Popup { _getSiteColor() { const color = [255, 255, 255]; - Popup._addColor(color, Popup._getColorInfo(window.getComputedStyle(document.documentElement).backgroundColor)); - Popup._addColor(color, Popup._getColorInfo(window.getComputedStyle(document.body).backgroundColor)); + const {documentElement, body} = document; + if (documentElement !== null) { + this._addColor(color, window.getComputedStyle(documentElement).backgroundColor); + } + if (body !== null) { + this._addColor(color, window.getComputedStyle(body).backgroundColor); + } const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128); return dark ? 'dark' : 'light'; } _invokeApi(action, params={}) { - const token = this._messageToken; - const contentWindow = this._container.contentWindow; - if (token === null || contentWindow === null) { return; } + const secret = this._frameSecret; + const token = this._frameToken; + const contentWindow = this._frame.contentWindow; + if (secret === null || token === null || contentWindow === null) { return; } - contentWindow.postMessage({action, params, token}, this._targetOrigin); + contentWindow.postMessage({action, params, secret, token}, this._targetOrigin); } - static _getFullscreenElement() { - return ( - document.fullscreenElement || - document.msFullscreenElement || - document.mozFullScreenElement || - document.webkitFullscreenElement || - null - ); + _getFrameParentElement() { + const defaultParent = document.body; + const fullscreenElement = DOM.getFullscreenElement(); + if ( + fullscreenElement === null || + fullscreenElement.shadowRoot || + fullscreenElement.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions + ) { + return defaultParent; + } + + switch (fullscreenElement.nodeName.toUpperCase()) { + case 'IFRAME': + case 'FRAME': + return defaultParent; + } + + return fullscreenElement; } - static _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) { + _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) { const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below'); const horizontalOffset = optionsGeneral.popupHorizontalOffset * offsetScale; const verticalOffset = optionsGeneral.popupVerticalOffset * offsetScale; - const [x, w] = Popup._getConstrainedPosition( + const [x, w] = this._getConstrainedPosition( elementRect.right - horizontalOffset, elementRect.left + horizontalOffset, width, @@ -388,7 +555,7 @@ class Popup { viewport.right, true ); - const [y, h, below] = Popup._getConstrainedPositionBinary( + const [y, h, below] = this._getConstrainedPositionBinary( elementRect.top - verticalOffset, elementRect.bottom + verticalOffset, height, @@ -399,12 +566,12 @@ class Popup { return [x, y, w, h, below]; } - static _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) { - const preferRight = Popup._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode); + _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) { + const preferRight = this._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode); const horizontalOffset = optionsGeneral.popupHorizontalOffset2 * offsetScale; const verticalOffset = optionsGeneral.popupVerticalOffset2 * offsetScale; - const [x, w] = Popup._getConstrainedPositionBinary( + const [x, w] = this._getConstrainedPositionBinary( elementRect.left - horizontalOffset, elementRect.right + horizontalOffset, width, @@ -412,7 +579,7 @@ class Popup { viewport.right, preferRight ); - const [y, h, below] = Popup._getConstrainedPosition( + const [y, h, below] = this._getConstrainedPosition( elementRect.bottom - verticalOffset, elementRect.top + verticalOffset, height, @@ -423,20 +590,22 @@ class Popup { return [x, y, w, h, below]; } - static _isVerticalTextPopupOnRight(positionPreference, writingMode) { + _isVerticalTextPopupOnRight(positionPreference, writingMode) { switch (positionPreference) { case 'before': - return !Popup._isWritingModeLeftToRight(writingMode); + return !this._isWritingModeLeftToRight(writingMode); case 'after': - return Popup._isWritingModeLeftToRight(writingMode); + return this._isWritingModeLeftToRight(writingMode); case 'left': return false; case 'right': return true; + default: + return false; } } - static _isWritingModeLeftToRight(writingMode) { + _isWritingModeLeftToRight(writingMode) { switch (writingMode) { case 'vertical-lr': case 'sideways-lr': @@ -446,7 +615,7 @@ class Popup { } } - static _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) { + _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) { size = Math.min(size, maxLimit - minLimit); let position; @@ -461,7 +630,7 @@ class Popup { return [position, size, after]; } - static _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) { + _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) { const overflowBefore = minLimit - (positionBefore - size); const overflowAfter = (positionAfter + size) - maxLimit; @@ -481,7 +650,10 @@ class Popup { return [position, size, after]; } - static _addColor(target, color) { + _addColor(target, cssColor) { + if (typeof cssColor !== 'string') { return; } + + const color = this._getColorInfo(cssColor); if (color === null) { return; } const a = color[3]; @@ -493,7 +665,7 @@ class Popup { } } - static _getColorInfo(cssColor) { + _getColorInfo(cssColor) { const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor); if (m === null) { return null; } @@ -506,7 +678,7 @@ class Popup { ]; } - static _getViewport(useVisualViewport) { + _getViewport(useVisualViewport) { const visualViewport = window.visualViewport; if (visualViewport !== null && typeof visualViewport === 'object') { const left = visualViewport.offsetLeft; @@ -531,87 +703,23 @@ class Popup { } } + const body = document.body; return { left: 0, top: 0, - right: document.body.clientWidth, + right: (body !== null ? body.clientWidth : 0), bottom: window.innerHeight }; } - static _isOnExtensionPage() { + static isFrameAboutBlank(frame) { try { - const url = chrome.runtime.getURL('/'); - return window.location.href.substring(0, url.length) === url; + const contentDocument = frame.contentDocument; + if (contentDocument === null) { return false; } + const url = contentDocument.location.href; + return /^about:blank(?:[#?]|$)/.test(url); } catch (e) { - // NOP - } - } - - static async _injectStylesheet(id, type, value, useWebExtensionApi) { - const injectedStylesheets = Popup._injectedStylesheets; - - if (Popup._isOnExtensionPage()) { - // Permissions error will occur if trying to use the WebExtension API to inject - // into an extension page. - useWebExtensionApi = false; - } - - let styleNode = injectedStylesheets.get(id); - if (typeof styleNode !== 'undefined') { - if (styleNode === null) { - // Previously injected via WebExtension API - throw new Error(`Stylesheet with id ${id} has already been injected using the WebExtension API`); - } - } else { - styleNode = null; + return false; } - - if (useWebExtensionApi) { - // Inject via WebExtension API - if (styleNode !== null && styleNode.parentNode !== null) { - styleNode.parentNode.removeChild(styleNode); - } - - await apiInjectStylesheet(type, value); - - injectedStylesheets.set(id, null); - return null; - } - - // Create node in document - const parentNode = document.head; - if (parentNode === null) { - throw new Error('No parent node'); - } - - // Create or reuse node - const isFile = (type === 'file'); - const tagName = isFile ? 'link' : 'style'; - if (styleNode === null || styleNode.nodeName.toLowerCase() !== tagName) { - if (styleNode !== null && styleNode.parentNode !== null) { - styleNode.parentNode.removeChild(styleNode); - } - styleNode = document.createElement(tagName); - styleNode.id = id; - } - - // Update node style - if (isFile) { - styleNode.rel = value; - } else { - styleNode.textContent = value; - } - - // Update parent - if (styleNode.parentNode !== parentNode) { - parentNode.appendChild(styleNode); - } - - // Add to map - injectedStylesheets.set(id, styleNode); - return styleNode; } } - -Popup._injectedStylesheets = new Map(); |