diff options
| -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; +    } +} |