diff options
Diffstat (limited to 'ext/js/pages/settings/keyboard-shortcuts-controller.js')
| -rw-r--r-- | ext/js/pages/settings/keyboard-shortcuts-controller.js | 367 | 
1 files changed, 367 insertions, 0 deletions
| diff --git a/ext/js/pages/settings/keyboard-shortcuts-controller.js b/ext/js/pages/settings/keyboard-shortcuts-controller.js new file mode 100644 index 00000000..99b16f06 --- /dev/null +++ b/ext/js/pages/settings/keyboard-shortcuts-controller.js @@ -0,0 +1,367 @@ +/* + * Copyright (C) 2021  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 + * KeyboardMouseInputField + */ + +class KeyboardShortcutController { +    constructor(settingsController) { +        this._settingsController = settingsController; +        this._entries = []; +        this._os = null; +        this._addButton = null; +        this._resetButton = null; +        this._listContainer = null; +        this._emptyIndicator = null; +        this._stringComparer = new Intl.Collator('en-US'); // Invariant locale +        this._scrollContainer = null; +    } + +    get settingsController() { +        return this._settingsController; +    } + +    async prepare() { +        const {platform: {os}} = await yomichan.api.getEnvironmentInfo(); +        this._os = os; + +        this._addButton = document.querySelector('#hotkey-list-add'); +        this._resetButton = document.querySelector('#hotkey-list-reset'); +        this._listContainer = document.querySelector('#hotkey-list'); +        this._emptyIndicator = document.querySelector('#hotkey-list-empty'); +        this._scrollContainer = document.querySelector('#keyboard-shortcuts-modal .modal-body'); + +        this._addButton.addEventListener('click', this._onAddClick.bind(this)); +        this._resetButton.addEventListener('click', this._onResetClick.bind(this)); +        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + +        await this._updateOptions(); +    } + +    async addEntry(terminationCharacterEntry) { +        const options = await this._settingsController.getOptions(); +        const {inputs: {hotkeys}} = options; + +        await this._settingsController.modifyProfileSettings([{ +            action: 'splice', +            path: 'inputs.hotkeys', +            start: hotkeys.length, +            deleteCount: 0, +            items: [terminationCharacterEntry] +        }]); + +        await this._updateOptions(); +        this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight; +    } + +    async deleteEntry(index) { +        const options = await this._settingsController.getOptions(); +        const {inputs: {hotkeys}} = options; + +        if (index < 0 || index >= hotkeys.length) { return false; } + +        await this._settingsController.modifyProfileSettings([{ +            action: 'splice', +            path: 'inputs.hotkeys', +            start: index, +            deleteCount: 1, +            items: [] +        }]); + +        await this._updateOptions(); +        return true; +    } + +    async modifyProfileSettings(targets) { +        return await this._settingsController.modifyProfileSettings(targets); +    } + +    async getDefaultHotkeys() { +        const defaultOptions = await this._settingsController.getDefaultOptions(); +        return defaultOptions.profiles[0].options.inputs.hotkeys; +    } + +    // Private + +    _onOptionsChanged({options}) { +        for (const entry of this._entries) { +            entry.cleanup(); +        } + +        this._entries = []; +        const {inputs: {hotkeys}} = options; +        const fragment = document.createDocumentFragment(); + +        for (let i = 0, ii = hotkeys.length; i < ii; ++i) { +            const hotkeyEntry = hotkeys[i]; +            const node = this._settingsController.instantiateTemplate('hotkey-list-item'); +            fragment.appendChild(node); +            const entry = new KeyboardShortcutHotkeyEntry(this, hotkeyEntry, i, node, this._os, this._stringComparer); +            this._entries.push(entry); +            entry.prepare(); +        } + +        this._listContainer.appendChild(fragment); +        this._listContainer.hidden = (hotkeys.length === 0); +        this._emptyIndicator.hidden = (hotkeys.length !== 0); +    } + +    _onAddClick(e) { +        e.preventDefault(); +        this._addNewEntry(); +    } + +    _onResetClick(e) { +        e.preventDefault(); +        this._reset(); +    } + +    async _addNewEntry() { +        const newEntry = { +            action: '', +            key: null, +            modifiers: [], +            scopes: ['popup', 'search'], +            enabled: true +        }; +        return await this.addEntry(newEntry); +    } + +    async _updateOptions() { +        const options = await this._settingsController.getOptions(); +        this._onOptionsChanged({options}); +    } + +    async _reset() { +        const value = await this.getDefaultHotkeys(); +        await this._settingsController.setProfileSetting('inputs.hotkeys', value); +        await this._updateOptions(); +    } +} + +class KeyboardShortcutHotkeyEntry { +    constructor(parent, data, index, node, os, stringComparer) { +        this._parent = parent; +        this._data = data; +        this._index = index; +        this._node = node; +        this._os = os; +        this._eventListeners = new EventListenerCollection(); +        this._inputField = null; +        this._actionSelect = null; +        this._scopeCheckboxes = null; +        this._scopeCheckboxContainers = null; +        this._basePath = `inputs.hotkeys[${this._index}]`; +        this._stringComparer = stringComparer; +    } + +    prepare() { +        const node = this._node; + +        const menuButton = node.querySelector('.hotkey-list-item-button'); +        const input = node.querySelector('.hotkey-list-item-input'); +        const action = node.querySelector('.hotkey-list-item-action'); +        const scopeCheckboxes = node.querySelectorAll('.hotkey-scope-checkbox'); +        const scopeCheckboxContainers = node.querySelectorAll('.hotkey-scope-checkbox-container'); +        const enabledToggle = node.querySelector('.hotkey-list-item-enabled'); + +        this._actionSelect = action; +        this._scopeCheckboxes = scopeCheckboxes; +        this._scopeCheckboxContainers = scopeCheckboxContainers; + +        this._inputField = new KeyboardMouseInputField(input, null, this._os); +        this._inputField.prepare(this._data.key, this._data.modifiers, false, true); + +        action.value = this._data.action; + +        enabledToggle.checked = this._data.enabled; +        enabledToggle.dataset.setting = `${this._basePath}.enabled`; + +        this._updateCheckboxVisibility(); +        this._updateCheckboxStates(); + +        for (const scopeCheckbox of scopeCheckboxes) { +            this._eventListeners.addEventListener(scopeCheckbox, 'change', this._onScopeCheckboxChange.bind(this), false); +        } +        this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false); +        this._eventListeners.addEventListener(this._actionSelect, 'change', this._onActionSelectChange.bind(this), false); +        this._eventListeners.on(this._inputField, 'change', this._onInputFieldChange.bind(this)); +    } + +    cleanup() { +        this._eventListeners.removeAllEventListeners(); +        this._inputField.cleanup(); +        if (this._node.parentNode !== null) { +            this._node.parentNode.removeChild(this._node); +        } +    } + +    // Private + +    _onMenuClose(e) { +        switch (e.detail.action) { +            case 'delete': +                this._delete(); +                break; +            case 'clearInputs': +                this._inputField.clearInputs(); +                break; +            case 'resetInput': +                this._resetInput(); +                break; +        } +    } + +    _onInputFieldChange({key, modifiers}) { +        this._setKeyAndModifiers(key, modifiers); +    } + +    _onScopeCheckboxChange(e) { +        const node = e.currentTarget; +        const {scope} = node.dataset; +        if (typeof scope !== 'string') { return; } +        this._setScopeEnabled(scope, node.checked); +    } + +    _onActionSelectChange(e) { +        const value = e.currentTarget.value; +        this._setAction(value); +    } + +    async _delete() { +        this._parent.deleteEntry(this._index); +    } + +    async _setKeyAndModifiers(key, modifiers) { +        this._data.key = key; +        this._data.modifiers = modifiers; +        await this._modifyProfileSettings([ +            { +                action: 'set', +                path: `${this._basePath}.key`, +                value: key +            }, +            { +                action: 'set', +                path: `${this._basePath}.modifiers`, +                value: modifiers +            } +        ]); +    } + +    async _setScopeEnabled(scope, enabled) { +        const scopes = this._data.scopes; +        const index = scopes.indexOf(scope); +        if ((index >= 0) === enabled) { return; } + +        if (enabled) { +            scopes.push(scope); +            const stringComparer = this._stringComparer; +            scopes.sort((scope1, scope2) => stringComparer.compare(scope1, scope2)); +        } else { +            scopes.splice(index, 1); +        } + +        await this._modifyProfileSettings([{ +            action: 'set', +            path: `${this._basePath}.scopes`, +            value: scopes +        }]); +    } + +    async _modifyProfileSettings(targets) { +        return await this._parent.settingsController.modifyProfileSettings(targets); +    } + +    async _resetInput() { +        const defaultHotkeys = await this._parent.getDefaultHotkeys(); +        const defaultValue = this._getDefaultKeyAndModifiers(defaultHotkeys, this._data.action); +        if (defaultValue === null) { return; } + +        const {key, modifiers} = defaultValue; +        await this._setKeyAndModifiers(key, modifiers); +        this._inputField.setInput(key, modifiers); +    } + +    _getDefaultKeyAndModifiers(defaultHotkeys, action) { +        for (const {action: action2, key, modifiers} of defaultHotkeys) { +            if (action2 !== action) { continue; } +            return {modifiers, key}; +        } +        return null; +    } + +    async _setAction(value) { +        const targets = [{ +            action: 'set', +            path: `${this._basePath}.action`, +            value +        }]; + +        this._data.action = value; + +        const scopes = this._data.scopes; +        const validScopes = this._getValidScopesForAction(value); +        if (validScopes !== null) { +            let changed = false; +            for (let i = 0, ii = scopes.length; i < ii; ++i) { +                if (!validScopes.has(scopes[i])) { +                    scopes.splice(i, 1); +                    --i; +                    --ii; +                    changed = true; +                } +            } +            if (changed) { +                if (scopes.length === 0) { +                    scopes.push(...validScopes); +                } +                targets.push({ +                    action: 'set', +                    path: `${this._basePath}.scopes`, +                    value: scopes +                }); +                this._updateCheckboxStates(); +            } +        } + +        await this._modifyProfileSettings(targets); + +        this._updateCheckboxVisibility(); +    } + +    _updateCheckboxStates() { +        const scopes = this._data.scopes; +        for (const scopeCheckbox of this._scopeCheckboxes) { +            scopeCheckbox.checked = scopes.includes(scopeCheckbox.dataset.scope); +        } +    } + +    _updateCheckboxVisibility() { +        const validScopes = this._getValidScopesForAction(this._data.action); +        for (const node of this._scopeCheckboxContainers) { +            node.hidden = !(validScopes === null || validScopes.has(node.dataset.scope)); +        } +    } + +    _getValidScopesForAction(action) { +        const optionNode = this._actionSelect.querySelector(`option[value="${action}"]`); +        const scopesString = (optionNode !== null ? optionNode.dataset.scopes : void 0); +        return (typeof scopesString === 'string' ? new Set(scopesString.split(' ')) : null); +    } +} |