diff options
author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2020-05-24 13:50:34 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-05-24 13:50:34 -0400 |
commit | 66e1185686f98f1cc4493298b5b1d4e0be7d826a (patch) | |
tree | 7d67a8bbbfd3d32235dcc5fbfe94c34e61132f3e | |
parent | c800444a0d4aa0177242da51e0f9716ebe882587 (diff) |
Settings binder (#542)
* Fix _modifySetting being async
* Return values for modifySettings's set and splice actions
* Add apiGetSettings
* Create a class which can accumulate tasks to run in bulk
* Create a class which binds input elements to customizable sources
* Create class which binds input elements to settings
* Add support for value transforms
* Remove redundant ObjectPropertyAccessor.getPathArray
* Fix not using correct types for input.min/max/step
* Fix wrong condition
* Use api object
-rw-r--r-- | ext/bg/js/backend.js | 90 | ||||
-rw-r--r-- | ext/bg/js/settings/dom-settings-binder.js | 122 | ||||
-rw-r--r-- | ext/mixed/js/api.js | 4 | ||||
-rw-r--r-- | ext/mixed/js/dom-data-binder.js | 349 | ||||
-rw-r--r-- | ext/mixed/js/task-accumulator.js | 81 |
5 files changed, 612 insertions, 34 deletions
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 8df4fd9d..90895737 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -119,7 +119,8 @@ class Backend { ['log', {async: false, contentScript: true, handler: this._onApiLog.bind(this)}], ['logIndicatorClear', {async: false, contentScript: true, handler: this._onApiLogIndicatorClear.bind(this)}], ['createActionPort', {async: false, contentScript: true, handler: this._onApiCreateActionPort.bind(this)}], - ['modifySettings', {async: true, contentScript: true, handler: this._onApiModifySettings.bind(this)}] + ['modifySettings', {async: true, contentScript: true, handler: this._onApiModifySettings.bind(this)}], + ['getSettings', {async: false, contentScript: true, handler: this._onApiGetSettings.bind(this)}] ]); this._messageHandlersWithProgress = new Map([ ['importDictionaryArchive', {async: true, contentScript: false, handler: this._onApiImportDictionaryArchive.bind(this)}], @@ -831,8 +832,8 @@ class Backend { const results = []; for (const target of targets) { try { - this._modifySetting(target); - results.push({result: true}); + const result = this._modifySetting(target); + results.push({result: utilIsolate(result)}); } catch (e) { results.push({error: errorToJson(e)}); } @@ -841,6 +842,19 @@ class Backend { return results; } + _onApiGetSettings({targets}) { + const results = []; + for (const target of targets) { + try { + const result = this._getSetting(target); + results.push({result: utilIsolate(result)}); + } catch (e) { + results.push({error: errorToJson(e)}); + } + } + return results; + } + // Command handlers _createActionListenerPort(port, sender, handlers) { @@ -1017,45 +1031,53 @@ class Backend { } } - async _modifySetting(target) { + _getSetting(target) { + const options = this._getModifySettingObject(target); + const accessor = new ObjectPropertyAccessor(options); + const {path} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + return accessor.get(ObjectPropertyAccessor.getPathArray(path)); + } + + _modifySetting(target) { const options = this._getModifySettingObject(target); const accessor = new ObjectPropertyAccessor(options); const action = target.action; switch (action) { case 'set': - { - const {path, value} = target; - if (typeof path !== 'string') { throw new Error('Invalid path'); } - accessor.set(ObjectPropertyAccessor.getPathArray(path), value); - } - break; + { + const {path, value} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + const pathArray = ObjectPropertyAccessor.getPathArray(path); + accessor.set(pathArray, value); + return accessor.get(pathArray); + } case 'delete': - { - const {path} = target; - if (typeof path !== 'string') { throw new Error('Invalid path'); } - accessor.delete(ObjectPropertyAccessor.getPathArray(path)); - } - break; + { + const {path} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + accessor.delete(ObjectPropertyAccessor.getPathArray(path)); + return true; + } case 'swap': - { - const {path1, path2} = target; - if (typeof path1 !== 'string') { throw new Error('Invalid path1'); } - if (typeof path2 !== 'string') { throw new Error('Invalid path2'); } - accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2)); - } - break; + { + const {path1, path2} = target; + if (typeof path1 !== 'string') { throw new Error('Invalid path1'); } + if (typeof path2 !== 'string') { throw new Error('Invalid path2'); } + accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2)); + return true; + } case 'splice': - { - const {path, start, deleteCount, items} = target; - if (typeof path !== 'string') { throw new Error('Invalid path'); } - if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); } - if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); } - if (!Array.isArray(items)) { throw new Error('Invalid items'); } - const array = accessor.get(ObjectPropertyAccessor.getPathArray(path)); - if (!Array.isArray(array)) { throw new Error('Invalid target type'); } - array.splice(start, deleteCount, ...items); - } - break; + { + const {path, start, deleteCount, items} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); } + if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); } + if (!Array.isArray(items)) { throw new Error('Invalid items'); } + const array = accessor.get(ObjectPropertyAccessor.getPathArray(path)); + if (!Array.isArray(array)) { throw new Error('Invalid target type'); } + return array.splice(start, deleteCount, ...items); + } default: throw new Error(`Unknown action: ${action}`); } diff --git a/ext/bg/js/settings/dom-settings-binder.js b/ext/bg/js/settings/dom-settings-binder.js new file mode 100644 index 00000000..0441ec29 --- /dev/null +++ b/ext/bg/js/settings/dom-settings-binder.js @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2020 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 + * DOMDataBinder + * api + * getOptionsContext + */ + +class DOMSettingsBinder { + constructor({getOptionsContext, transforms=null}) { + this._getOptionsContext = getOptionsContext; + this._defaultScope = 'profile'; + this._dataBinder = new DOMDataBinder({ + selector: '[data-setting]', + createElementMetadata: this._createElementMetadata.bind(this), + compareElementMetadata: this._compareElementMetadata.bind(this), + getValues: this._getValues.bind(this), + setValues: this._setValues.bind(this) + }); + this._transforms = new Map(transforms !== null ? transforms : []); + } + + observe(element) { + this._dataBinder.observe(element); + } + + disconnect() { + this._dataBinder.disconnect(); + } + + refresh() { + this._dataBinder.refresh(); + } + + // Private + + _createElementMetadata(element) { + return { + path: element.dataset.setting, + scope: element.dataset.scope, + transformPre: element.dataset.transformPre, + transformPost: element.dataset.transformPost + }; + } + + _compareElementMetadata(metadata1, metadata2) { + return ( + metadata1.path === metadata2.path && + metadata1.scope === metadata2.scope && + metadata1.transformPre === metadata2.transformPre && + metadata1.transformPost === metadata2.transformPost + ); + } + + async _getValues(targets) { + const settingsTargets = []; + for (const {metadata: {path, scope}} of targets) { + const target = { + path, + scope: scope || this._defaultScope + }; + if (target.scope === 'profile') { + target.optionsContext = this._getOptionsContext(); + } + settingsTargets.push(target); + } + return this._transformResults(await api.getSettings(settingsTargets), targets); + } + + async _setValues(targets) { + const settingsTargets = []; + for (const {metadata, value, element} of targets) { + const {path, scope, transformPre} = metadata; + const target = { + path, + scope: scope || this._defaultScope, + action: 'set', + value: this._transform(value, transformPre, metadata, element) + }; + if (target.scope === 'profile') { + target.optionsContext = this._getOptionsContext(); + } + settingsTargets.push(target); + } + return this._transformResults(await api.modifySettings(settingsTargets), targets); + } + + _transform(value, transform, metadata, element) { + if (typeof transform === 'string') { + const transformFunction = this._transforms.get(transform); + if (typeof transformFunction !== 'undefined') { + value = transformFunction(value, metadata, element); + } + } + return value; + } + + _transformResults(values, targets) { + return values.map((value, i) => { + const error = value.error; + if (error) { return jsonToError(error); } + const {metadata, element} = targets[i]; + const result = this._transform(value.result, metadata.transformPost, metadata, element); + return {result}; + }); + } +} diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index e09a0db6..2d5ad9e7 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -172,6 +172,10 @@ const api = (() => { return this._invoke('modifySettings', {targets, source}); } + getSettings(targets) { + return this._invoke('getSettings', {targets}); + } + // Invoke functions with progress importDictionaryArchive(archiveContent, details, onProgress) { diff --git a/ext/mixed/js/dom-data-binder.js b/ext/mixed/js/dom-data-binder.js new file mode 100644 index 00000000..05a84240 --- /dev/null +++ b/ext/mixed/js/dom-data-binder.js @@ -0,0 +1,349 @@ +/* + * Copyright (C) 2020 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 + * 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._mutationObserver = new MutationObserver(this._onMutation.bind(this)); + this._observingElement = null; + this._elementMap = new Map(); // Map([element => observer]...) + this._elementAncestorMap = new Map(); // Map([element => Set([observer]...)) + } + + observe(element) { + if (this._isObserving) { return; } + + this._observingElement = element; + this._mutationObserver.observe(element, { + attributes: true, + attributeOldValue: true, + childList: true, + subtree: true + }); + this._onMutation([{ + type: 'childList', + target: element.parentNode, + addedNodes: [element], + removedNodes: [] + }]); + } + + disconnect() { + if (!this._isObserving) { return; } + + this._mutationObserver.disconnect(); + this._observingElement = null; + + for (const observer of this._elementMap.values()) { + this._removeObserver(observer); + } + } + + async refresh() { + await this._updateTasks.enqueue(null, {all: true}); + } + + // 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 (addedNodes.length !== 0 || addedNodes.length !== 0) { + const observer = this._elementMap.get(target); + if (typeof observer !== 'undefined') { + observer.updateValue(); + } + } + } + + _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); + } + } + + 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._elementMap.values()) { + 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) { + if (this._elementMap.has(element) || this._shouldIgnoreElement(element)) { return; } + + const metadata = this._createElementMetadata(element); + const nodeName = element.nodeName.toUpperCase(); + const ancestors = this._getAncestors(element); + const observer = { + element, + ancestors, + type: (nodeName === 'INPUT' ? element.type : null), + value: null, + hasValue: false, + onChange: null, + metadata + }; + observer.onChange = this._onElementChange.bind(this, observer); + this._elementMap.set(element, observer); + + element.addEventListener('change', observer.onChange, false); + + 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); + } + + this._updateTasks.enqueue(observer); + } + + _removeObserver(observer) { + const {element, ancestors} = observer; + + element.removeEventListener('change', observer.onChange, false); + observer.onChange = null; + + 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); + } + } + } + + _isObserverStale(observer) { + const {element, type, metadata} = observer; + const nodeName = element.nodeName.toUpperCase(); + return !( + type === (nodeName === 'INPUT' ? element.type : null) && + this._compareElementMetadata(metadata, this._createElementMetadata(element)) + ); + } + + _shouldIgnoreElement(element) { + for (const selector of this._ignoreSelectors) { + if (element.matches(selector)) { + return true; + } + } + return false; + } + + _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; + } + + _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; + } + } + + _getElementValue(element) { + switch (element.nodeName.toUpperCase()) { + case 'INPUT': + switch (element.type) { + case 'checkbox': + return !!element.checked; + case 'text': + return `${element.value}`; + case 'number': + return this._getInputNumberValue(element); + } + break; + case 'TEXTAREA': + case 'SELECT': + return element.value; + } + return null; + } + + _getInputNumberValue(element) { + let value = parseFloat(element.value); + if (!Number.isFinite(value)) { return 0; } + + let {min, max, step} = element; + min = this._stringValueToNumberOrNull(min); + max = this._stringValueToNumberOrNull(max); + step = this._stringValueToNumberOrNull(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; + } + + _stringValueToNumberOrNull(value) { + if (typeof value !== 'string' || value.length === 0) { + return null; + } + + const number = parseFloat(value); + return !Number.isNaN(number) ? number : null; + } +} diff --git a/ext/mixed/js/task-accumulator.js b/ext/mixed/js/task-accumulator.js new file mode 100644 index 00000000..5c6fe312 --- /dev/null +++ b/ext/mixed/js/task-accumulator.js @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2020 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 TaskAccumulator { + constructor(runTasks) { + this._deferPromise = null; + this._activePromise = null; + this._tasks = []; + this._tasksActive = []; + this._uniqueTasks = new Map(); + this._uniqueTasksActive = new Map(); + this._runTasksBind = this._runTasks.bind(this); + this._tasksCompleteBind = this._tasksComplete.bind(this); + this._runTasks = runTasks; + } + + enqueue(key, data) { + if (this._deferPromise === null) { + const promise = this._activePromise !== null ? this._activePromise : Promise.resolve(); + this._deferPromise = promise.then(this._runTasksBind); + } + + const task = {data, stale: false}; + if (key !== null) { + const activeTaskInfo = this._uniqueTasksActive.get(key); + if (typeof activeTaskInfo !== 'undefined') { + activeTaskInfo.stale = true; + } + + this._uniqueTasks.set(key, task); + } else { + this._tasks.push(task); + } + + return this._deferPromise; + } + + _runTasks() { + this._deferPromise = null; + + // Swap + [this._tasks, this._tasksActive] = [this._tasksActive, this._tasks]; + [this._uniqueTasks, this._uniqueTasksActive] = [this._uniqueTasksActive, this._uniqueTasks]; + + const promise = this._runTasksAsync(); + this._activePromise = promise.then(this._tasksCompleteBind); + return this._activePromise; + } + + async _runTasksAsync() { + try { + const allTasks = [ + ...this._tasksActive.map((taskInfo) => [null, taskInfo]), + ...this._uniqueTasksActive.entries() + ]; + await this._runTasks(allTasks); + } catch (e) { + yomichan.logError(e); + } + } + + _tasksComplete() { + this._tasksActive.length = 0; + this._uniqueTasksActive.clear(); + this._activePromise = null; + } +} |