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
This commit is contained in:
toasted-nutbread 2020-06-24 21:46:13 -04:00 committed by GitHub
parent 96932119f8
commit 3e68af8666
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 119 additions and 20 deletions

View File

@ -110,7 +110,8 @@
"showPitchAccentPositionNotation", "showPitchAccentPositionNotation",
"showPitchAccentGraph", "showPitchAccentGraph",
"showIframePopupsInRootFrame", "showIframePopupsInRootFrame",
"useSecurePopupFrameUrl" "useSecurePopupFrameUrl",
"usePopupShadowDom"
], ],
"properties": { "properties": {
"enable": { "enable": {
@ -252,6 +253,10 @@
"useSecurePopupFrameUrl": { "useSecurePopupFrameUrl": {
"type": "boolean", "type": "boolean",
"default": true "default": true
},
"usePopupShadowDom": {
"type": "boolean",
"default": true
} }
} }
}, },

View File

@ -108,6 +108,7 @@ class Backend {
['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}], ['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}],
['frameInformationGet', {async: true, contentScript: true, handler: this._onApiFrameInformationGet.bind(this)}], ['frameInformationGet', {async: true, contentScript: true, handler: this._onApiFrameInformationGet.bind(this)}],
['injectStylesheet', {async: true, contentScript: true, handler: this._onApiInjectStylesheet.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)}], ['getEnvironmentInfo', {async: false, contentScript: true, handler: this._onApiGetEnvironmentInfo.bind(this)}],
['clipboardGet', {async: true, contentScript: true, handler: this._onApiClipboardGet.bind(this)}], ['clipboardGet', {async: true, contentScript: true, handler: this._onApiClipboardGet.bind(this)}],
['getDisplayTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetDisplayTemplatesHtml.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() { _onApiGetEnvironmentInfo() {
return this.environment.getInfo(); return this.environment.getInfo();
} }

View File

@ -177,7 +177,8 @@ function profileOptionsCreateDefaults() {
showPitchAccentPositionNotation: true, showPitchAccentPositionNotation: true,
showPitchAccentGraph: false, showPitchAccentGraph: false,
showIframePopupsInRootFrame: false, showIframePopupsInRootFrame: false,
useSecurePopupFrameUrl: true useSecurePopupFrameUrl: true,
usePopupShadowDom: true
}, },
audio: { audio: {

View File

@ -186,6 +186,10 @@
<label><input type="checkbox" data-setting="general.useSecurePopupFrameUrl"> Use secure popup frame URL</label> <label><input type="checkbox" data-setting="general.useSecurePopupFrameUrl"> Use secure popup frame URL</label>
</div> </div>
<div class="checkbox options-advanced">
<label><input type="checkbox" data-setting="general.usePopupShadowDom"> Use shadow DOM container for popup</label>
</div>
<div class="checkbox options-advanced"> <div class="checkbox options-advanced">
<label><input type="checkbox" id="show-debug-info" data-setting="general.debugInfo" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-general-debug-info"> Show debug information</label> <label><input type="checkbox" id="show-debug-info" data-setting="general.debugInfo" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-general-debug-info"> Show debug information</label>
</div> </div>

View File

@ -345,7 +345,7 @@ class Frontend {
} }
_ignoreElements() { _ignoreElements() {
return this._popup === null || this._popup.isProxy() ? [] : [this._popup.getFrame()]; return this._popup === null || this._popup.isProxy() ? [] : [this._popup.getContainer()];
} }
_ignorePoint(x, y) { _ignorePoint(x, y) {

View File

@ -47,6 +47,9 @@ class Popup {
this._frame.style.width = '0'; this._frame.style.width = '0';
this._frame.style.height = '0'; this._frame.style.height = '0';
this._container = this._frame;
this._shadow = null;
this._fullscreenEventListeners = new EventListenerCollection(); this._fullscreenEventListeners = new EventListenerCollection();
} }
@ -180,7 +183,12 @@ class Popup {
} }
async setCustomOuterCss(css, useWebExtensionApi) { 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) { setChildrenSupported(value) {
@ -195,6 +203,10 @@ class Popup {
return this._frame.getBoundingClientRect(); return this._frame.getBoundingClientRect();
} }
getContainer() {
return this._container;
}
// Private functions // Private functions
_inject() { _inject() {
@ -330,9 +342,9 @@ class Popup {
throw new Error('Options not initialized'); 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) => { const {secret, token} = await this._initializeFrame(this._frame, this._targetOrigin, this._frameId, (frame) => {
frame.removeAttribute('src'); frame.removeAttribute('src');
@ -382,9 +394,9 @@ class Popup {
} }
_resetFrame() { _resetFrame() {
const parent = this._frame.parentNode; const parent = this._container.parentNode;
if (parent !== null) { if (parent !== null) {
parent.removeChild(this._frame); parent.removeChild(this._container);
} }
this._frame.removeAttribute('src'); this._frame.removeAttribute('src');
this._frame.removeAttribute('srcdoc'); this._frame.removeAttribute('srcdoc');
@ -395,9 +407,31 @@ class Popup {
this._injectPromiseComplete = false; 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() { async _injectStyles() {
try { try {
await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); await this._injectPopupOuterStylesheet();
} catch (e) { } catch (e) {
// NOP // 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) { _observeFullscreen(observe) {
if (!observe) { if (!observe) {
this._fullscreenEventListeners.removeAllEventListeners(); this._fullscreenEventListeners.removeAllEventListeners();
@ -425,8 +471,8 @@ class Popup {
_onFullscreenChanged() { _onFullscreenChanged() {
const parent = this._getFrameParentElement(); const parent = this._getFrameParentElement();
if (parent !== null && this._frame.parentNode !== parent) { if (parent !== null && this._container.parentNode !== parent) {
parent.appendChild(this._frame); parent.appendChild(this._container);
} }
} }

View File

@ -121,6 +121,10 @@ const api = (() => {
return this._invoke('injectStylesheet', {type, value}); return this._invoke('injectStylesheet', {type, value});
} }
getStylesheetContent(url) {
return this._invoke('getStylesheetContent', {url});
}
getEnvironmentInfo() { getEnvironmentInfo() {
return this._invoke('getEnvironmentInfo'); return this._invoke('getEnvironmentInfo');
} }

View File

@ -21,14 +21,36 @@
const dynamicLoader = (() => { const dynamicLoader = (() => {
const injectedStylesheets = new Map(); 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)) { if (useWebExtensionApi && yomichan.isExtensionUrl(window.location.href)) {
// Permissions error will occur if trying to use the WebExtension API to inject into an extension page // Permissions error will occur if trying to use the WebExtension API to inject into an extension page
useWebExtensionApi = false; useWebExtensionApi = false;
} }
let styleNode = injectedStylesheets.get(id); let styleNode = getInjectedStylesheet(id, parentNode);
if (typeof styleNode !== 'undefined') { if (typeof styleNode !== 'undefined') {
if (styleNode === null) { if (styleNode === null) {
// Previously injected via WebExtension API // Previously injected via WebExtension API
@ -38,22 +60,31 @@ const dynamicLoader = (() => {
styleNode = null; styleNode = null;
} }
if (type === 'file-content') {
value = await api.getStylesheetContent(value);
type = 'code';
useWebExtensionApi = false;
}
if (useWebExtensionApi) { if (useWebExtensionApi) {
// Inject via WebExtension API // Inject via WebExtension API
if (styleNode !== null && styleNode.parentNode !== null) { if (styleNode !== null && styleNode.parentNode !== null) {
styleNode.parentNode.removeChild(styleNode); styleNode.parentNode.removeChild(styleNode);
} }
injectedStylesheets.set(id, null); setInjectedStylesheet(id, parentNode, null);
await api.injectStylesheet(type, value); await api.injectStylesheet(type, value);
return null; return null;
} }
// Create node in document // Create node in document
const parentNode = document.head; let parentNode2 = parentNode;
if (parentNode === null) { if (parentNode2 === null) {
parentNode2 = document.head;
if (parentNode2 === null) {
throw new Error('No parent node'); throw new Error('No parent node');
} }
}
// Create or reuse node // Create or reuse node
const isFile = (type === 'file'); const isFile = (type === 'file');
@ -74,12 +105,12 @@ const dynamicLoader = (() => {
} }
// Update parent // Update parent
if (styleNode.parentNode !== parentNode) { if (styleNode.parentNode !== parentNode2) {
parentNode.appendChild(styleNode); parentNode2.appendChild(styleNode);
} }
// Add to map // Add to map
injectedStylesheets.set(id, styleNode); setInjectedStylesheet(id, parentNode, styleNode);
return styleNode; return styleNode;
} }