From 3e68af8666bdf9a6d8d605f7a3bb0432c8d6cb33 Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Wed, 24 Jun 2020 21:46:13 -0400 Subject: [PATCH] Shadow DOM container for popup iframes (#623) * Add support for injecting stylesheets into a custom parent node * Add api.getStylesheetContent * Add support for injecting a CSS file's content * Add usePopupShadowDom option * Use a per-parentNode cache * Add support for using a shadow DOM wrapper around popup iframes * Ignore the popup container instead of the frame --- ext/bg/data/options-schema.json | 7 +++- ext/bg/js/backend.js | 8 +++++ ext/bg/js/options.js | 3 +- ext/bg/settings.html | 4 +++ ext/fg/js/frontend.js | 2 +- ext/fg/js/popup.js | 62 ++++++++++++++++++++++++++++----- ext/mixed/js/api.js | 4 +++ ext/mixed/js/dynamic-loader.js | 49 +++++++++++++++++++++----- 8 files changed, 119 insertions(+), 20 deletions(-) diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json index f8791433..b56017bc 100644 --- a/ext/bg/data/options-schema.json +++ b/ext/bg/data/options-schema.json @@ -110,7 +110,8 @@ "showPitchAccentPositionNotation", "showPitchAccentGraph", "showIframePopupsInRootFrame", - "useSecurePopupFrameUrl" + "useSecurePopupFrameUrl", + "usePopupShadowDom" ], "properties": { "enable": { @@ -252,6 +253,10 @@ "useSecurePopupFrameUrl": { "type": "boolean", "default": true + }, + "usePopupShadowDom": { + "type": "boolean", + "default": true } } }, diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index b89cb641..344706d1 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -108,6 +108,7 @@ class Backend { ['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}], ['frameInformationGet', {async: true, contentScript: true, handler: this._onApiFrameInformationGet.bind(this)}], ['injectStylesheet', {async: true, contentScript: true, handler: this._onApiInjectStylesheet.bind(this)}], + ['getStylesheetContent', {async: true, contentScript: true, handler: this._onApiGetStylesheetContent.bind(this)}], ['getEnvironmentInfo', {async: false, contentScript: true, handler: this._onApiGetEnvironmentInfo.bind(this)}], ['clipboardGet', {async: true, contentScript: true, handler: this._onApiClipboardGet.bind(this)}], ['getDisplayTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetDisplayTemplatesHtml.bind(this)}], @@ -719,6 +720,13 @@ class Backend { }); } + async _onApiGetStylesheetContent({url}) { + if (!url.startsWith('/') || url.startsWith('//') || !url.endsWith('.css')) { + throw new Error('Invalid URL'); + } + return await requestText(url, 'GET'); + } + _onApiGetEnvironmentInfo() { return this.environment.getInfo(); } diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index 151c945b..ccc56848 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -177,7 +177,8 @@ function profileOptionsCreateDefaults() { showPitchAccentPositionNotation: true, showPitchAccentGraph: false, showIframePopupsInRootFrame: false, - useSecurePopupFrameUrl: true + useSecurePopupFrameUrl: true, + usePopupShadowDom: true }, audio: { diff --git a/ext/bg/settings.html b/ext/bg/settings.html index 4de70b7e..51cb14e7 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -186,6 +186,10 @@ +
+ +
+
diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index ae0953f9..f6b0d236 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -345,7 +345,7 @@ class Frontend { } _ignoreElements() { - return this._popup === null || this._popup.isProxy() ? [] : [this._popup.getFrame()]; + return this._popup === null || this._popup.isProxy() ? [] : [this._popup.getContainer()]; } _ignorePoint(x, y) { diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index 3b14d3d0..5ee62c9b 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -47,6 +47,9 @@ class Popup { this._frame.style.width = '0'; this._frame.style.height = '0'; + this._container = this._frame; + this._shadow = null; + this._fullscreenEventListeners = new EventListenerCollection(); } @@ -180,7 +183,12 @@ class Popup { } async setCustomOuterCss(css, useWebExtensionApi) { - return await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi); + let parentNode = null; + if (this._shadow !== null) { + useWebExtensionApi = false; + parentNode = this._shadow; + } + return await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi, parentNode); } setChildrenSupported(value) { @@ -195,6 +203,10 @@ class Popup { return this._frame.getBoundingClientRect(); } + getContainer() { + return this._container; + } + // Private functions _inject() { @@ -330,9 +342,9 @@ class Popup { throw new Error('Options not initialized'); } - const {useSecurePopupFrameUrl} = this._options.general; + const {useSecurePopupFrameUrl, usePopupShadowDom} = this._options.general; - this._injectStyles(); + await this._setUpContainer(usePopupShadowDom); const {secret, token} = await this._initializeFrame(this._frame, this._targetOrigin, this._frameId, (frame) => { frame.removeAttribute('src'); @@ -382,9 +394,9 @@ class Popup { } _resetFrame() { - const parent = this._frame.parentNode; + const parent = this._container.parentNode; if (parent !== null) { - parent.removeChild(this._frame); + parent.removeChild(this._container); } this._frame.removeAttribute('src'); this._frame.removeAttribute('srcdoc'); @@ -395,9 +407,31 @@ class Popup { this._injectPromiseComplete = false; } + async _setUpContainer(usePopupShadowDom) { + if (usePopupShadowDom && typeof this._frame.attachShadow === 'function') { + const container = document.createElement('div'); + container.style.setProperty('all', 'initial', 'important'); + const shadow = container.attachShadow({mode: 'closed', delegatesFocus: true}); + shadow.appendChild(this._frame); + + this._container = container; + this._shadow = shadow; + } else { + const frameParentNode = this._frame.parentNode; + if (frameParentNode !== null) { + frameParentNode.removeChild(this._frame); + } + + this._container = this._frame; + this._shadow = null; + } + + await this._injectStyles(); + } + async _injectStyles() { try { - await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); + await this._injectPopupOuterStylesheet(); } catch (e) { // NOP } @@ -409,6 +443,18 @@ class Popup { } } + async _injectPopupOuterStylesheet() { + let fileType = 'file'; + let useWebExtensionApi = true; + let parentNode = null; + if (this._shadow !== null) { + fileType = 'file-content'; + useWebExtensionApi = false; + parentNode = this._shadow; + } + await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', fileType, '/fg/css/client.css', useWebExtensionApi, parentNode); + } + _observeFullscreen(observe) { if (!observe) { this._fullscreenEventListeners.removeAllEventListeners(); @@ -425,8 +471,8 @@ class Popup { _onFullscreenChanged() { const parent = this._getFrameParentElement(); - if (parent !== null && this._frame.parentNode !== parent) { - parent.appendChild(this._frame); + if (parent !== null && this._container.parentNode !== parent) { + parent.appendChild(this._container); } } diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index c54196e2..5c17d50e 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -121,6 +121,10 @@ const api = (() => { return this._invoke('injectStylesheet', {type, value}); } + getStylesheetContent(url) { + return this._invoke('getStylesheetContent', {url}); + } + getEnvironmentInfo() { return this._invoke('getEnvironmentInfo'); } diff --git a/ext/mixed/js/dynamic-loader.js b/ext/mixed/js/dynamic-loader.js index 37f85112..981d1ee5 100644 --- a/ext/mixed/js/dynamic-loader.js +++ b/ext/mixed/js/dynamic-loader.js @@ -21,14 +21,36 @@ const dynamicLoader = (() => { const injectedStylesheets = new Map(); + const injectedStylesheetsWithParent = new WeakMap(); - async function loadStyle(id, type, value, useWebExtensionApi=false) { + function getInjectedStylesheet(id, parentNode) { + if (parentNode === null) { + return injectedStylesheets.get(id); + } + const map = injectedStylesheetsWithParent.get(parentNode); + return typeof map !== 'undefined' ? map.get(id) : void 0; + } + + function setInjectedStylesheet(id, parentNode, value) { + if (parentNode === null) { + injectedStylesheets.set(id, value); + return; + } + let map = injectedStylesheetsWithParent.get(parentNode); + if (typeof map === 'undefined') { + map = new Map(); + injectedStylesheetsWithParent.set(parentNode, map); + } + map.set(id, value); + } + + async function loadStyle(id, type, value, useWebExtensionApi=false, parentNode=null) { if (useWebExtensionApi && yomichan.isExtensionUrl(window.location.href)) { // Permissions error will occur if trying to use the WebExtension API to inject into an extension page useWebExtensionApi = false; } - let styleNode = injectedStylesheets.get(id); + let styleNode = getInjectedStylesheet(id, parentNode); if (typeof styleNode !== 'undefined') { if (styleNode === null) { // Previously injected via WebExtension API @@ -38,21 +60,30 @@ const dynamicLoader = (() => { styleNode = null; } + if (type === 'file-content') { + value = await api.getStylesheetContent(value); + type = 'code'; + useWebExtensionApi = false; + } + if (useWebExtensionApi) { // Inject via WebExtension API if (styleNode !== null && styleNode.parentNode !== null) { styleNode.parentNode.removeChild(styleNode); } - injectedStylesheets.set(id, null); + setInjectedStylesheet(id, parentNode, null); await api.injectStylesheet(type, value); return null; } // Create node in document - const parentNode = document.head; - if (parentNode === null) { - throw new Error('No parent node'); + let parentNode2 = parentNode; + if (parentNode2 === null) { + parentNode2 = document.head; + if (parentNode2 === null) { + throw new Error('No parent node'); + } } // Create or reuse node @@ -74,12 +105,12 @@ const dynamicLoader = (() => { } // Update parent - if (styleNode.parentNode !== parentNode) { - parentNode.appendChild(styleNode); + if (styleNode.parentNode !== parentNode2) { + parentNode2.appendChild(styleNode); } // Add to map - injectedStylesheets.set(id, styleNode); + setInjectedStylesheet(id, parentNode, styleNode); return styleNode; }