/* * Copyright (C) 2019-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 * DOM * conditionsNormalizeOptionValue */ class ConditionsUI { static instantiateTemplate(templateSelector) { const template = document.querySelector(templateSelector); const content = document.importNode(template.content, true); return $(content.firstChild); } } ConditionsUI.Container = class Container { constructor(conditionDescriptors, conditionNameDefault, conditionGroups, container, addButton) { this.children = []; this.conditionDescriptors = conditionDescriptors; this.conditionNameDefault = conditionNameDefault; this.conditionGroups = conditionGroups; this.container = container; this.addButton = addButton; this.container.empty(); for (const conditionGroup of toIterable(conditionGroups)) { this.children.push(new ConditionsUI.ConditionGroup(this, conditionGroup)); } this.addButton.on('click', this.onAddConditionGroup.bind(this)); } cleanup() { for (const child of this.children) { child.cleanup(); } this.addButton.off('click'); this.container.empty(); } save() { // Override } isolate(object) { // Override return object; } remove(child) { const index = this.children.indexOf(child); if (index < 0) { return; } child.cleanup(); this.children.splice(index, 1); this.conditionGroups.splice(index, 1); } onAddConditionGroup() { const conditionGroup = this.isolate({ conditions: [this.createDefaultCondition(this.conditionNameDefault)] }); this.conditionGroups.push(conditionGroup); this.save(); this.children.push(new ConditionsUI.ConditionGroup(this, conditionGroup)); } createDefaultCondition(type) { let operator = ''; let value = ''; if (hasOwn(this.conditionDescriptors, type)) { const conditionDescriptor = this.conditionDescriptors[type]; operator = conditionDescriptor.defaultOperator; ({value} = this.getOperatorDefaultValue(type, operator)); if (typeof value === 'undefined') { value = ''; } } return {type, operator, value}; } getOperatorDefaultValue(type, operator) { if (hasOwn(this.conditionDescriptors, type)) { const conditionDescriptor = this.conditionDescriptors[type]; if (hasOwn(conditionDescriptor.operators, operator)) { const operatorDescriptor = conditionDescriptor.operators[operator]; if (hasOwn(operatorDescriptor, 'defaultValue')) { return {value: this.isolate(operatorDescriptor.defaultValue), fromOperator: true}; } } if (hasOwn(conditionDescriptor, 'defaultValue')) { return {value: this.isolate(conditionDescriptor.defaultValue), fromOperator: false}; } } return {fromOperator: false}; } }; ConditionsUI.ConditionGroup = class ConditionGroup { constructor(parent, conditionGroup) { this.parent = parent; this.children = []; this.conditionGroup = conditionGroup; this.container = $('<div>').addClass('condition-group').appendTo(parent.container); this.options = ConditionsUI.instantiateTemplate('#condition-group-options-template').appendTo(parent.container); this.separator = ConditionsUI.instantiateTemplate('#condition-group-separator-template').appendTo(parent.container); this.addButton = this.options.find('.condition-add'); for (const condition of toIterable(conditionGroup.conditions)) { this.children.push(new ConditionsUI.Condition(this, condition)); } this.addButton.on('click', this.onAddCondition.bind(this)); } cleanup() { for (const child of this.children) { child.cleanup(); } this.addButton.off('click'); this.container.remove(); this.options.remove(); this.separator.remove(); } save() { this.parent.save(); } isolate(object) { return this.parent.isolate(object); } remove(child) { const index = this.children.indexOf(child); if (index < 0) { return; } child.cleanup(); this.children.splice(index, 1); this.conditionGroup.conditions.splice(index, 1); if (this.children.length === 0) { this.parent.remove(this, false); } } onAddCondition() { const condition = this.isolate(this.parent.createDefaultCondition(this.parent.conditionNameDefault)); this.conditionGroup.conditions.push(condition); this.children.push(new ConditionsUI.Condition(this, condition)); } }; ConditionsUI.Condition = class Condition { constructor(parent, condition) { this.parent = parent; this.condition = condition; this.container = ConditionsUI.instantiateTemplate('#condition-template').appendTo(parent.container); this.input = this.container.find('.condition-input'); this.inputInner = null; this.typeSelect = this.container.find('.condition-type'); this.operatorSelect = this.container.find('.condition-operator'); this.removeButton = this.container.find('.condition-remove'); this.updateTypes(); this.updateOperators(); this.updateInput(); this.typeSelect.on('change', this.onConditionTypeChanged.bind(this)); this.operatorSelect.on('change', this.onConditionOperatorChanged.bind(this)); this.removeButton.on('click', this.onRemoveClicked.bind(this)); } cleanup() { this.inputInner.off('change'); this.typeSelect.off('change'); this.operatorSelect.off('change'); this.removeButton.off('click'); this.container.remove(); } save() { this.parent.save(); } isolate(object) { return this.parent.isolate(object); } updateTypes() { const conditionDescriptors = this.parent.parent.conditionDescriptors; const optionGroup = this.typeSelect.find('optgroup'); optionGroup.empty(); for (const type of Object.keys(conditionDescriptors)) { const conditionDescriptor = conditionDescriptors[type]; $('<option>').val(type).text(conditionDescriptor.name).appendTo(optionGroup); } this.typeSelect.val(this.condition.type); } updateOperators() { const conditionDescriptors = this.parent.parent.conditionDescriptors; const optionGroup = this.operatorSelect.find('optgroup'); optionGroup.empty(); const type = this.condition.type; if (hasOwn(conditionDescriptors, type)) { const conditionDescriptor = conditionDescriptors[type]; const operators = conditionDescriptor.operators; for (const operatorName of Object.keys(operators)) { const operatorDescriptor = operators[operatorName]; $('<option>').val(operatorName).text(operatorDescriptor.name).appendTo(optionGroup); } } this.operatorSelect.val(this.condition.operator); } updateInput() { const conditionDescriptors = this.parent.parent.conditionDescriptors; const {type, operator} = this.condition; const objects = []; let inputType = null; if (hasOwn(conditionDescriptors, type)) { const conditionDescriptor = conditionDescriptors[type]; objects.push(conditionDescriptor); if (hasOwn(conditionDescriptor, 'type')) { inputType = conditionDescriptor.type; } if (hasOwn(conditionDescriptor.operators, operator)) { const operatorDescriptor = conditionDescriptor.operators[operator]; objects.push(operatorDescriptor); if (hasOwn(operatorDescriptor, 'type')) { inputType = operatorDescriptor.type; } } } this.input.empty(); if (inputType === 'select') { this.inputInner = this.createSelectElement(objects); } else if (inputType === 'keyMulti') { this.inputInner = this.createInputKeyMultiElement(objects); } else { this.inputInner = this.createInputElement(objects); } this.inputInner.appendTo(this.input); this.inputInner.on('change', this.onInputChanged.bind(this)); const {valid, value} = this.validateValue(this.condition.value); this.inputInner.toggleClass('is-invalid', !valid); this.inputInner.val(value); } createInputElement(objects) { const inputInner = ConditionsUI.instantiateTemplate('#condition-input-text-template'); const props = new Map([ ['placeholder', ''], ['type', 'text'] ]); for (const object of objects) { if (hasOwn(object, 'placeholder')) { props.set('placeholder', object.placeholder); } if (object.type === 'number') { props.set('type', 'number'); for (const prop of ['step', 'min', 'max']) { if (hasOwn(object, prop)) { props.set(prop, object[prop]); } } } } for (const [prop, value] of props.entries()) { inputInner.prop(prop, value); } return inputInner; } createInputKeyMultiElement(objects) { const inputInner = this.createInputElement(objects); inputInner.prop('readonly', true); let values = []; let keySeparator = ' + '; for (const object of objects) { if (hasOwn(object, 'values')) { values = object.values; } if (hasOwn(object, 'keySeparator')) { keySeparator = object.keySeparator; } } const pressedKeyIndices = new Set(); const onKeyDown = ({originalEvent}) => { const pressedKeyEventName = DOM.getKeyFromEvent(originalEvent); if (pressedKeyEventName === 'Escape' || pressedKeyEventName === 'Backspace') { pressedKeyIndices.clear(); inputInner.val(''); inputInner.change(); return; } const pressedModifiers = DOM.getActiveModifiers(originalEvent); // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey // https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta // It works with mouse events on some platforms, so try to determine if metaKey is pressed // hack; only works when Shift and Alt are not pressed const isMetaKeyChrome = ( pressedKeyEventName === 'Meta' && getSetDifference(new Set(['shift', 'alt']), pressedModifiers).size !== 0 ); if (isMetaKeyChrome) { pressedModifiers.add('meta'); } for (const modifier of pressedModifiers) { const foundIndex = values.findIndex(({optionValue}) => optionValue === modifier); if (foundIndex !== -1) { pressedKeyIndices.add(foundIndex); } } const inputValue = [...pressedKeyIndices].map((i) => values[i].name).join(keySeparator); inputInner.val(inputValue); inputInner.change(); }; inputInner.on('keydown', onKeyDown); return inputInner; } createSelectElement(objects) { const inputInner = ConditionsUI.instantiateTemplate('#condition-input-select-template'); const data = new Map([ ['values', []], ['defaultValue', null] ]); for (const object of objects) { if (hasOwn(object, 'values')) { data.set('values', object.values); } if (hasOwn(object, 'defaultValue')) { data.set('defaultValue', this.isolate(object.defaultValue)); } } for (const {optionValue, name} of data.get('values')) { const option = ConditionsUI.instantiateTemplate('#condition-input-option-template'); option.attr('value', optionValue); option.text(name); option.appendTo(inputInner); } const defaultValue = data.get('defaultValue'); if (defaultValue !== null) { inputInner.val(this.isolate(defaultValue)); } return inputInner; } validateValue(value, isInput=false) { const conditionDescriptors = this.parent.parent.conditionDescriptors; let valid = true; let inputTransformedValue = null; try { [value, inputTransformedValue] = conditionsNormalizeOptionValue( conditionDescriptors, this.condition.type, this.condition.operator, value, isInput ); } catch (e) { valid = false; } return {valid, value, inputTransformedValue}; } onInputChanged() { const {valid, value, inputTransformedValue} = this.validateValue(this.inputInner.val(), true); this.inputInner.toggleClass('is-invalid', !valid); this.inputInner.val(value); this.condition.value = inputTransformedValue !== null ? inputTransformedValue : value; this.save(); } onConditionTypeChanged() { const type = this.typeSelect.val(); const {operator, value} = this.parent.parent.createDefaultCondition(type); this.condition.type = type; this.condition.operator = operator; this.condition.value = value; this.save(); this.updateOperators(); this.updateInput(); } onConditionOperatorChanged() { const type = this.condition.type; const operator = this.operatorSelect.val(); const {value, fromOperator} = this.parent.parent.getOperatorDefaultValue(type, operator); this.condition.operator = operator; if (fromOperator) { this.condition.value = value; } this.save(); this.updateInput(); } onRemoveClicked() { this.parent.remove(this); this.save(); } };