From 4f8f88ec9bb6ff64df811e4ea2acf36e1a1f4863 Mon Sep 17 00:00:00 2001 From: Thomas O'Brien Date: Wed, 15 Apr 2020 13:21:08 +0100 Subject: [PATCH] Library now runs in individual script's scope without needing unsafeWindow --- README.md | 69 ++-------- pardus_options_library.js | 280 +++++++++++++++++++++----------------- 2 files changed, 172 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index acf2821..00961ce 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,13 @@ At the top of your Tampermonkey script, ensure you have the following lines: // @include http*://*.pardus.at/options.php // @grant GM_setValue // @grant GM_getValue -// @grant unsafeWindow -// @require https://raw.githubusercontent.com/Tro95/Pardus-Options-Library/v1.4/pardus_options_library.js +// @require https://raw.githubusercontent.com/Tro95/Pardus-Options-Library/v2.0/pardus_options_library.js ``` -The `GM_setValue` and `GM_getValue` methods are required to persistently store the user's settings. The `unsafeWindow` object is required to allow multiple scripts to use this library concurrently. It is the only supported way to interact with the library. +The `GM_setValue` and `GM_getValue` methods are required to persistently store the user's settings. ### Multiple Scripts -This library is safe to be included by multiple scripts. Tampermonkey runs each of the user's script in turn, and each script using this library will attempt to load it in and embed it onto the page in the `unsafeWindow` object. If the library has already been embedded by a prior script, loading in the library for a second time will perform a version check, and the greater version will be kept. In swapping an older version for a greater version, all internal objects will be copied over. +This library is safe to be included by multiple scripts. ## Usage @@ -24,18 +23,16 @@ if (document.location.pathname === '/options.php') { // Option-related logic goes here } ``` -The library is available through a singleton object `unsafeWindow.PardusOptions`. It is recommended to abstract your options logic into a separate function. +The library is available through a static object `PardusOptions`. It is recommended to abstract your options logic into a separate function. ```javascript function myOptionsLogic() { /** * Create the tab for your script */ - const myScriptsTab = unsafeWindow.PardusOptions.addTab({ + const myScriptsTab = PardusOptions.addTab({ heading: 'My Script', - id: 'my-script', - saveFunction: GM_setValue, - getFunction: GM_getValue + id: 'my-script' }); /** @@ -62,59 +59,23 @@ if (document.location.pathname === '/options.php') { } ``` -### Saving Variables And Scoping - -One of the early design decisions was whether to make the library a shared singleton object between all scripts, or let each script have their own version. Due to performance and complexity constraints the shared singleton approach was chosen, however this has caused some problems with Tampermonkey scoping. - -From Tampermonkey's point of view, this library will exist only within the scope of one script at any point in time, and this script will be the one to have embedded its instance of the library object `unsafeWindow.PardusOptions`. Any other script attempting to access the `unsafeWindow.PardusOptions` object will cause any Tampermonkey-related methods to execute in the scope of the initial script. This is particularly problematic for the `GM_getValue` and `GM_setValue` methods, as all values being saved in all scripts using this library (when on the options page) will be saved in the scope of a single script, and irretrievable outside the library. - -To solve this problem the library has abstracted away the `GM_getValue` and `GM_setValue` methods, and instead allows you to pass in the `GM_getValue` and `GM_setValue` methods from your script that are correctly scoped at runtime to all library object constructors. These methods are passed down to all smaller objects in turn, so it is recommended to set them on the `unsafeWindow.PardusOptions.addTab()` call, as opposd to any further down. If you plan to share the same tab with multiple scripts, then you will have to pass the `GM_getValue` and `GM_setValue` methods in at the `OptionsContent.addBox()` call instead. - -If you ever encounter log lines saying `Default save function not overridden, script cannot save key '${key}' with value '${value}'` or `Default get function not overridden, script cannot get key '${key}' with default value ${defaultValue}'`, it means somepart of the library has not received the correctly-scoped `GM_getValue` and `GM_setValue` methods and that you should pass them in. - ## Reference ### PardusOptions Object -`unsafeWindow.PardusOptions` +`PardusOptions` #### Creation -This is a singleton object that you should not construct, and as such the constructor is not documented here. Including the library in your script will automatically create it on the relevant options page. +This is a static object that you should not construct, and as such the constructor is not documented here. Including the library in your script will automatically initialise it on the relevant options page. #### Methods ##### version -Static method, returning the version of the library. +Returns the version of the library. ```javascript -unsafeWindow.PardusOptions.constructor.version(); +PardusOptions.version(); ``` ##### addTab Creates a new tab and returns an object allowing manipulation of the content of the tab. Recommended usage is one tab per script. ```javascript -unsafeWindow.PardusOptions.addTab({ - id, - heading, - saveFunction = PardusOptionsUtility.defaultSaveFunction, - getFunction = PardusOptionsUtility.defaultGetFunction, -}); -``` -**id** [*Required*]: An identification string with no spaces that must be unique across all scripts using this library. -**heading** [*Required*]: The heading of the tab. You should ensure this string is not too long to break the formatting. -**saveFunction** [*Optional*]: A reference to a function that can save a persistent value for you. It is highly recommended to put a value of `GM_setValue` here. -**getFunction** [*Optional*]: A reference to a function that can retrieve a persistent value for you. It is highly recommended to put a value of `GM_getValue` here. - -Returns an object of type OptionsContent, allowing manipulation of the content within the tab's content area. - -Example: -```javascript -const myTab = unsafeWindow.PardusOptions.addTab({ - id: 'my-scripts-tab', - heading: 'My Script', - saveFunction: GM_setValue, - getFunction: GM_getValue, -}); -``` -##### addOrGetTab -Returns an existing tab with the same `id`, or creates a new tab if it does not exist. This is to support sharing tabs across scripts in a safer manner, as the execution order of scripts is not guaranteed. -```javascript -unsafeWindow.PardusOptions.addOrGetTab({ +PardusOptions.addTab({ id, heading, saveFunction = PardusOptionsUtility.defaultSaveFunction, @@ -123,14 +84,14 @@ unsafeWindow.PardusOptions.addOrGetTab({ ``` **id** [*Required*]: An identification string with no spaces that must be unique across all scripts using this library. **heading** [*Required*]: The heading of the tab. You should ensure this string is not too long to break the formatting. -**saveFunction** [*Optional*]: A reference to a function that can save a persistent value for you. It is highly recommended to put a value of `GM_setValue` here unless you are sharing the tab across multiple scripts. -**getFunction** [*Optional*]: A reference to a function that can retrieve a persistent value for you. It is highly recommended to put a value of `GM_getValue` here unless you are sharing the tab across multiple scripts. +**saveFunction** [*Optional*]: A reference to a function that can save a persistent value for you. This is automatically set to the script's `GM_setValue` method. +**getFunction** [*Optional*]: A reference to a function that can retrieve a persistent value for you. This is automatically set to the script's `GM_getValue` method. Returns an object of type OptionsContent, allowing manipulation of the content within the tab's content area. Example: ```javascript -const myTab = unsafeWindow.PardusOptions.addOrGetTab({ +const myTab = PardusOptions.addTab({ id: 'my-scripts-tab', heading: 'My Script', saveFunction: GM_setValue, @@ -141,7 +102,7 @@ const myTab = unsafeWindow.PardusOptions.addOrGetTab({ ### Class OptionsContent Represents all the content within a single tab, allowing for easy creation of OptionsBoxes. #### Creation -You should use the `unsafeWindow.PardusOptions.addTab()` method to obtain an OptionsContent object, and as such the constructor is not documented here. +You should use the `PardusOptions.addTab()` method to obtain an OptionsContent object, and as such the constructor is not documented here. #### Methods ##### addBox diff --git a/pardus_options_library.js b/pardus_options_library.js index 82f8283..2ad4bcb 100644 --- a/pardus_options_library.js +++ b/pardus_options_library.js @@ -1,12 +1,12 @@ -/* global unsafeWindow */ +/* global GM_setValue, GM_getValue */ class PardusOptionsUtility { static defaultSaveFunction(key, value) { - console.warn(`Default save function not overridden, script cannot save key '${key}' with value '${value}'`); + return GM_setValue(key, value); } static defaultGetFunction(key, defaultValue = null) { - console.warn(`Default get function not overridden, script cannot get key '${key}' with default value ${defaultValue}'`); + return GM_getValue(key, defaultValue); } /** @@ -31,11 +31,19 @@ class PardusOptionsUtility { static getVariableName(variableName) { return `${this.getUniverse()}_${variableName}`; } + + static setActiveTab(id) { + window.localStorage.setItem('pardusOptionsOpenTab', id); + window.dispatchEvent(new window.Event('storage')); + } } class HtmlElement { constructor(id) { // Make sure it is a valid html identifier + if (!id || id === '') { + throw new Error('Id cannot be empty.'); + } const validIds = RegExp('^[a-zA-Z][\\w:.-]*$'); if (!validIds.test(id)) { throw new Error(`Id '${id}' is not a valid HTML identifier.`); @@ -46,6 +54,13 @@ class HtmlElement { this.beforeRefreshHooks = []; } + addEventListener(eventName, listener) { + this.getElement().addEventListener(eventName, listener, false); + this.addAfterRefreshHook(() => { + this.getElement().addEventListener(eventName, listener, false); + }); + } + toString() { return `
`; } @@ -81,6 +96,14 @@ class HtmlElement { template.innerHTML = this.toString(); return template.content.firstChild; } + + appendChild(ele) { + return document.getElementById(this.id).appendChild(ele); + } + + appendTableChild(ele) { + return document.getElementById(this.id).firstChild.appendChild(ele); + } } /** @@ -109,7 +132,7 @@ class DescriptionElement extends HtmlElement { this.styling = `style="${style}"`; }, toString() { - return ``; + return ''; }, }; this.frontContainer.setId(id); @@ -150,7 +173,7 @@ class DescriptionElement extends HtmlElement { html = `${html}${this.description}`; } } else { - html = `${html}${this.description}`; + html = `${html}${this.description}`; } if (this.imageRight && this.imageRight !== '') { @@ -170,6 +193,7 @@ class AbstractOption extends HtmlElement { saveFunction = PardusOptionsUtility.defaultSaveFunction, getFunction = PardusOptionsUtility.defaultGetFunction, shallow = false, + reverse = false, }) { super(id); this.variable = variable; @@ -179,13 +203,17 @@ class AbstractOption extends HtmlElement { this.defaultValue = defaultValue; this.inputId = `${this.id}-input`; this.shallow = shallow; + this.reverse = reverse; } toString() { if (this.shallow) { return `${this.getInnerHTML()}`; } - return `${this.description}${this.getInnerHTML()}`; + if (this.reverse) { + return `${this.getInnerHTML()}`; + } + return `${this.getInnerHTML()}`; } getInnerHTML() { @@ -495,6 +523,11 @@ class OptionsGroup extends HtmlElement { this.saveFunction = saveFunction; this.getFunction = getFunction; this.options = []; + this.addAfterRefreshHook(() => { + for (const option of this.options) { + option.afterRefreshElement(); + } + }); } addBooleanOption({ @@ -749,6 +782,15 @@ class OptionsContent extends HtmlElement { this.getFunction = getFunction; this.leftBoxes = []; this.rightBoxes = []; + this.topBoxes = []; + this.addAfterRefreshHook(() => { + for (const box of this.leftBoxes) { + box.afterRefreshElement(); + } + for (const box of this.rightBoxes) { + box.afterRefreshElement(); + } + }); } addBox({ @@ -759,9 +801,20 @@ class OptionsContent extends HtmlElement { imageRight = '', saveFunction = this.saveFunction, getFunction = this.getFunction, + top = false, }) { let newBox = null; - if (this.leftBoxes.length <= this.rightBoxes.length) { + if (top) { + newBox = this.addBoxTop({ + heading, + premium, + description, + imageLeft, + imageRight, + saveFunction, + getFunction, + }); + } else if (this.leftBoxes.length <= this.rightBoxes.length) { newBox = this.addBoxLeft({ heading, premium, @@ -785,6 +838,30 @@ class OptionsContent extends HtmlElement { return newBox; } + addBoxTop({ + heading, + premium = false, + description = '', + imageLeft = '', + imageRight = '', + saveFunction = this.saveFunction, + getFunction = this.getFunction, + }) { + const newBox = new OptionsBox({ + id: `${this.id}-top-box-${this.topBoxes.length}`, + heading, + premium, + description, + imageLeft, + imageRight, + saveFunction, + getFunction, + }); + this.topBoxes.push(newBox); + this.refreshElement(); + return newBox; + } + addBoxLeft({ heading, premium = false, @@ -894,16 +971,7 @@ class OptionsContent extends HtmlElement { if (this.content !== null) { return this.content; } - return ``; - } - - afterRefreshElement() { - for (const box of this.leftBoxes) { - box.afterRefreshElement(); - } - for (const box of this.rightBoxes) { - box.afterRefreshElement(); - } + return ` PardusOptionsUtility.setActiveTab(this.id), true); + window.addEventListener('storage', () => { + if (window.localStorage.getItem('pardusOptionsOpenTab') === this.id && !this.active) { + this.setActive(); + } + if (window.localStorage.getItem('pardusOptionsOpenTab') !== this.id && this.active) { + this.setInactive(); + } + }); + } + setActive() { this.label.setActive(); this.content.setActive(); @@ -994,71 +1074,69 @@ class Tab { } } +class TabsRow extends HtmlElement { + constructor({ + id, + }) { + super(id); + } + + addLabel({ + label, + }) { + this.appendChild(label.toElement()); + } + + toString() { + return ``; + } +} + class TabsElement extends HtmlElement { constructor({ id, - labels = [], }) { super(id); - this.labels = labels; + this.tabsRow = new TabsRow({ + id: `${this.id}-row`, + }); } addLabel({ label, }) { - this.labels.push(label); + this.tabsRow.addLabel({ + label, + }); } toString() { - return `${this.labels.join('')}
`; + return `${this.tabsRow}
`; } } class ContentsArea extends HtmlElement { constructor({ id, - contents = [], }) { super(id); - this.contents = contents; } addContent({ content, }) { - this.contents.push(content); + this.appendChild(document.createElement('div').appendChild(content.toElement())); + content.afterRefreshElement(); } toString() { - return `${this.contents.join('')}`; - } - - afterRefreshElement() { - for (const content of this.contents) { - content.afterRefreshElement(); - } + return ``; } } -class PardusOptions extends HtmlElement { - constructor({ - upgradeOptions = false, - tabs = [], - labels = [], - contents = [], - } = {}) { - super('options-area'); - this.tabs = tabs; - this.tabsElement = new TabsElement({ - id: 'options-tabs', - labels, - }); - this.contentElement = new ContentsArea({ - id: 'options-content', - contents - }); - - if (upgradeOptions) { +class PardusOptions { + static init() { + if (document.getElementById('options-area')) { return; } @@ -1073,7 +1151,7 @@ class PardusOptions extends HtmlElement { defaultPardusOptionsContent.remove(); // Add this object to the DOM within the main containing element - pardusMainElement.appendChild(this.toElement()); + pardusMainElement.appendChild(this.getPardusOptionsElement()); // Add the Pardus options back in this.addTab({ @@ -1083,14 +1161,32 @@ class PardusOptions extends HtmlElement { }); // Set the Pardus options tab to be active by default - this.setActiveTab('pardus-default'); + PardusOptionsUtility.setActiveTab('pardus-default'); } static version() { - return 1.5; + return 1.6; + } + + static getTabsElement() { + return new TabsElement({ + id: 'options-tabs', + }); + } + + static getContentElement() { + return new ContentsArea({ + id: 'options-content', + }); + } + + static getPardusOptionsElement() { + const template = document.createElement('template'); + template.innerHTML = `${this.getContentElement()}
${this.getTabsElement()}
`; + return template.content.firstChild; } - addTab({ + static addTab({ id, heading, content = null, @@ -1106,89 +1202,27 @@ class PardusOptions extends HtmlElement { }); // Check for id uniqueness - for (const tab of this.tabs) { - if (tab.id === newTab.id) { - throw new Error(`Tab '${newTab.id}' already exists!`); - } + if (document.getElementById(newTab.id)) { + throw new Error(`Tab '${newTab.id}' already exists!`); } - this.tabs.push(newTab); - this.tabsElement.addLabel({ + this.getTabsElement().addLabel({ label: newTab.getLabel(), }); - this.contentElement.addContent({ + + this.getContentElement().addContent({ content: newTab.getContent(), }); - this.refreshElement(); + newTab.addListeners(); return newTab.getContent(); } - - /** - * Allows safe fetching of existing tabs, creating the tab if it doesn't exist. This is useful - * when multiple scripts share the same tab, and the order of execution isn't guaranteed. - */ - addOrGetTab({ - id, - heading, - saveFunction = PardusOptionsUtility.defaultSaveFunction, - getFunction = PardusOptionsUtility.defaultGetFunction, - }) { - // Check to see if the tab already exists, and if so return it - for (const tab of this.tabs) { - if (tab.id === id) { - return tab.getContent(); - } - } - - // If we reach here it means the tab doesn't currently exist, so create it - return this.addTab({ - id, - heading, - saveFunction, - getFunction, - }); - } - - setActiveTab(id) { - for (const tab of this.tabs) { - if (tab.id === id) { - tab.setActive(); - } else { - tab.setInactive(); - } - } - } - - afterRefreshElement() { - // Add the tab-switching logic - for (const tab of this.tabs) { - tab.getLabel().getElement().addEventListener('click', () => this.setActiveTab(tab.id), true); - } - - this.contentElement.afterRefreshElement(); - } - - toString() { - return `${this.contentElement}
${this.tabsElement}
`; - } } /** * Add the Options object to the page for all scripts to use */ if (document.location.pathname === '/options.php') { - if (typeof unsafeWindow.PardusOptions === 'undefined' || !unsafeWindow.PardusOptions) { - unsafeWindow.PardusOptions = new PardusOptions(); - } else if (unsafeWindow.PardusOptions && typeof unsafeWindow.PardusOptions.constructor.version === 'function' && unsafeWindow.PardusOptions.constructor.version() < PardusOptions.version()) { - // Upgrade the version if two scripts use different ones - console.log(`Upgrading Pardus Options Library from version ${unsafeWindow.PardusOptions.constructor.version()} to ${PardusOptions.version()}.`); - unsafeWindow.PardusOptions = new PardusOptions({ - upgradeOptions: true, - tabs: unsafeWindow.PardusOptions.tabs, - labels: unsafeWindow.PardusOptions.tabsElement.labels, - contents: unsafeWindow.PardusOptions.contentElement.contents, - }); - } + PardusOptions.init(); }