Browse Source

feat: improve <setting-text>

* show error text as an element
* add Ctrl-S hotkey to save
* remove all custom alerts for onSave
* show "Saved" in the "Save" button label same as Vacuum->Vacuumed
* inline two trivial components in index
* include more tags in isInput()
tophf 2 years ago
parent
commit
38231b6e0d

+ 3 - 0
src/_locales/en/messages.yml

@@ -85,6 +85,9 @@ buttonSave:
 buttonSaveClose:
   description: Button to save modifications of a script and then close the editing page.
   message: Save & Close
+buttonSaved:
+  description: Button text after saving.
+  message: Saved
 buttonShowEditorState:
   description: Button to show the list of currently used CodeMirror options.
   message: Show editor state

+ 2 - 2
src/common/keyboard.js

@@ -6,8 +6,8 @@ export const keyboardService = new KeyboardService();
 
 bindKeys();
 
-export function isInput(el) {
-  return ['input', 'textarea'].includes(el?.tagName?.toLowerCase());
+export function isInput({ localName: n } = {}) {
+  return n === 'input' || n === 'button' || n === 'select' || n === 'textarea';
 }
 
 function handleFocus(e) {

+ 54 - 30
src/common/ui/setting-text.vue

@@ -1,32 +1,38 @@
 <template>
-  <div>
+  <div class="setting-text">
     <textarea
       ref="text"
       class="monospace-font"
-      :class="{'has-error': parsedData.error}"
+      :class="{'has-error': error}"
       spellcheck="false"
-      v-model="value"
+      v-model="text"
       :disabled="disabled"
-      :title="parsedData.error"
       :placeholder="placeholder"
-      :rows="rows || calcRows(value)"
+      :rows="rows || calcRows(text)"
       @change="onChange"
+      @ctrl-s="onSave"
     />
-    <button v-if="hasSave" v-text="i18n('buttonSave')" @click="onSave"
+    <button v-if="hasSave" v-text="saved || i18n('buttonSave')" @click="onSave" :title="ctrlS"
             :disabled="disabled || !canSave"/>
     <button v-if="hasReset" v-text="i18n('buttonReset')" @click="onReset"
             :disabled="disabled || !canReset"/>
-    <slot/>
+    <!-- DANGER! Keep the error tag in one line to keep the space which ensures the first word
+         is selected correctly without the preceding button's text on double-click. -->
+    <slot/> <span class="error text-red sep" v-text="error" v-if="json"/>
   </div>
 </template>
 
 <script>
+import { i18n } from '@/common';
 import { getUnloadSentry } from '@/common/router';
+import { modifiers } from '@violentmonkey/shortcut';
 import { deepEqual, objectGet } from '../object';
 import options from '../options';
 import defaults from '../options-defaults';
 import hookSetting from '../hook-setting';
 
+const ctrlS = modifiers.ctrlcmd === 'm' ? '⌘S' : 'Ctrl-S';
+
 export default {
   props: {
     name: String,
@@ -41,42 +47,47 @@ export default {
   },
   data() {
     return {
-      value: null,
+      ctrlS,
+      error: null,
       placeholder: null,
       savedValue: null,
+      saved: '',
+      text: '',
+      value: null,
     };
   },
   computed: {
-    parsedData() {
-      let value;
-      let error;
-      if (this.json) {
-        try {
-          value = JSON.parse(this.value);
-        } catch (e) {
-          error = e.message || e;
-        }
-      } else {
-        value = this.value;
-      }
-      return { value, error };
-    },
     isDirty() {
-      return !deepEqual(this.parsedData.value, this.savedValue || '');
+      return !deepEqual(this.value, this.savedValue || '');
     },
     canSave() {
-      return !this.parsedData.error && this.isDirty;
+      return !this.error && this.isDirty;
     },
     canReset() {
-      return !deepEqual(this.parsedData.value, this.defaultValue || '');
+      return !deepEqual(this.value, this.defaultValue || '');
     },
   },
   watch: {
     isDirty(state) {
       this.toggleUnloadSentry(state);
     },
-  },
-  created() {
+    text(str) {
+      let value;
+      let error;
+      if (this.json) {
+        try {
+          value = JSON.parse(str);
+        } catch (e) {
+          error = e.message;
+        }
+        this.error = error;
+      } else {
+        value = str;
+      }
+      this.value = value;
+      this.saved = '';
+    },
+  },  created() {
     const handle = this.json
       ? (value => JSON.stringify(value, null, '  '))
       // XXX compatible with old data format
@@ -84,7 +95,7 @@ export default {
     const defaultValue = objectGet(defaults, this.name);
     this.revoke = hookSetting(this.name, val => {
       this.savedValue = val;
-      this.value = handle(val);
+      this.text = handle(val);
     });
     this.defaultValue = defaultValue;
     this.placeholder = handle(defaultValue);
@@ -93,7 +104,7 @@ export default {
       // The component won't be destroyed on tab change, so the changes are actually kept.
       // Here we reset it to make sure the user loses the changes when leaving the settings tab.
       // Otherwise the user may be confused about where the changes are after switching back.
-      this.value = handle(this.savedValue);
+      this.text = handle(this.savedValue);
     });
   },
   beforeUnmount() {
@@ -106,7 +117,8 @@ export default {
       if (!this.hasSave && this.canSave) this.onSave();
     },
     onSave() {
-      options.set(this.name, this.parsedData.value).catch(this.bgError);
+      options.set(this.name, this.value).catch(this.bgError);
+      this.saved = i18n('buttonSaved');
       this.$emit('save');
     },
     onReset() {
@@ -131,3 +143,15 @@ export default {
   },
 };
 </script>
+
+<style>
+.setting-text {
+  > .error {
+    /* We've used .sep so our error text aligns with the buttons, now we need to undo some parts */
+    display: inline;
+    &::after {
+      content: none;
+    }
+  }
+}
+</style>

+ 23 - 13
src/options/views/tab-settings/index.vue

@@ -131,10 +131,21 @@
           </locale-group>
         </div>
       </section>
+
       <vm-editor />
-      <vm-template />
+
+      <section>
+        <h3 v-text="i18n('labelScriptTemplate')"/>
+        <setting-text name="scriptTemplate" has-reset/>
+      </section>
+
       <vm-blacklist />
-      <vm-css />
+
+      <section>
+        <h3 v-text="i18n('labelCustomCSS')"/>
+        <p v-html="i18n('descCustomCSS')"/>
+        <setting-text name="customCSS"/>
+      </section>
     </details>
   </div>
 </template>
@@ -149,16 +160,15 @@ import { forEachEntry, mapEntry } from '@/common/object';
 import options from '@/common/options';
 import optionsDefaults from '@/common/options-defaults';
 import hookSetting from '@/common/hook-setting';
+import { keyboardService } from '@/common/keyboard';
 import { focusMe } from '@/common/ui';
 import LocaleGroup from '@/common/ui/locale-group';
-import loadZip from '@/common/zip';
+import SettingText from '@/common/ui/setting-text';
 import VmImport from './vm-import';
 import VmExport from './vm-export';
 import VmSync from './vm-sync';
 import VmEditor from './vm-editor';
-import VmTemplate from './vm-template';
 import VmBlacklist from './vm-blacklist';
-import VmCss from './vm-css';
 
 const badgeColorEnum = {
   badgeColor: i18n('titleBadgeColor'),
@@ -233,10 +243,9 @@ export default {
     VmExport,
     VmSync,
     VmEditor,
-    VmTemplate,
     VmBlacklist,
-    VmCss,
     SettingCheck,
+    SettingText,
     LocaleGroup,
     Tooltip,
   },
@@ -256,6 +265,9 @@ export default {
     },
   },
   methods: {
+    ctrlS() {
+      document.activeElement.dispatchEvent(new Event('ctrl-s'));
+    },
     onResetBadgeColors() {
       badgeColorNames.forEach(name => {
         settings[name] = optionsDefaults[name];
@@ -264,18 +276,16 @@ export default {
   },
   activated() {
     focusMe(this.$el);
-  },
-  created() {
-    this.revokers = [];
+    this.revokers = [
+      keyboardService.register('ctrlcmd-s', this.ctrlS, { condition: 'inputFocus' }),
+    ];
     items::forEachEntry(([name, { normalize = normalizeEnum }]) => {
       this.revokers.push(hookSetting(name, val => { settings[name] = normalize(val, name); }));
       this.$watch(() => settings[name], getItemUpdater(name, normalize));
     });
     this.expose = Object.keys(options.get(EXPOSE)).map(k => [k, decodeURIComponent(k)]);
-    // Preload zip.js when user visits settings tab
-    loadZip();
   },
-  beforeUnmount() {
+  deactivated() {
     this.revokers.forEach((revoke) => { revoke(); });
   },
 };

+ 1 - 7
src/options/views/tab-settings/vm-blacklist.vue

@@ -6,7 +6,7 @@
       <a href="https://violentmonkey.github.io/posts/smart-rules-for-blacklist/#blacklist-patterns" target="_blank" rel="noopener noreferrer" v-text="i18n('learnBlacklist')"></a>
     </p>
     <div class="flex flex-wrap">
-      <setting-text name="blacklist" class="flex-1" @save="onSave" @bgError="errors = $event"/>
+      <setting-text name="blacklist" class="flex-1" @bgError="errors = $event"/>
       <ol v-if="errors" class="text-red">
         <li v-for="e in errors" :key="e" v-text="e"/>
       </ol>
@@ -17,7 +17,6 @@
 <script>
 import { sendCmdDirectly } from '@/common';
 import { BLACKLIST_ERRORS } from '@/common/consts';
-import { showMessage } from '@/common/ui';
 import SettingText from '@/common/ui/setting-text';
 
 export default {
@@ -29,11 +28,6 @@ export default {
       errors: null,
     };
   },
-  methods: {
-    onSave() {
-      showMessage({ text: this.i18n('msgSavedBlacklist') });
-    },
-  },
   async mounted() {
     this.errors = await sendCmdDirectly('Storage', ['base', 'getOne', BLACKLIST_ERRORS]);
   },

+ 0 - 23
src/options/views/tab-settings/vm-css.vue

@@ -1,23 +0,0 @@
-<template>
-  <section>
-    <h3 v-text="i18n('labelCustomCSS')"></h3>
-    <p v-html="i18n('descCustomCSS')"></p>
-    <setting-text name="customCSS" @save="onSave"/>
-  </section>
-</template>
-
-<script>
-import { showMessage } from '@/common/ui';
-import SettingText from '@/common/ui/setting-text';
-
-export default {
-  components: {
-    SettingText,
-  },
-  methods: {
-    onSave() {
-      showMessage({ text: this.i18n('msgSavedCustomCSS') });
-    },
-  },
-};
-</script>

+ 3 - 7
src/options/views/tab-settings/vm-editor.vue

@@ -12,7 +12,7 @@
       <p v-text="error"/>
     </div>
     <p v-html="i18n('descEditorOptions')" class="my-1"/>
-    <setting-text name="editor" ref="editor" :json="true" :has-reset="true" @save="onSave">
+    <setting-text name="editor" json has-reset @dblclick="toggleBoolean">
       <button v-text="i18n('buttonShowEditorState')" @click="toggleStateHint"/>
     </setting-text>
     <pre v-text="hint" class="monospace-font dim-hint" />
@@ -22,7 +22,6 @@
 <script>
 import options from '@/common/options';
 import hookSetting from '@/common/hook-setting';
-import { showMessage } from '@/common/ui';
 import SettingText from '@/common/ui/setting-text';
 
 const keyThemeCSS = 'editorTheme';
@@ -69,7 +68,6 @@ export default {
     this.revokers = null;
   },
   async mounted() {
-    this.$refs.editor.$el::addEventListener('dblclick', this.toggleBoolean);
     if (!this.revokers) {
       this.css = makeTextPreview(options.get(keyThemeCSS));
       this.revokers = [
@@ -101,13 +99,11 @@ export default {
         this.$nextTick(() => el?.focus());
       }
     },
-    onSave() {
-      showMessage({ text: this.$refs.editor.error || this.i18n('msgSavedEditorOptions') });
-    },
     toggleBoolean(event) {
       const el = /** @type {HTMLTextAreaElement} */ event.target;
       const { selectionStart: start, selectionEnd: end, value } = el;
-      const toggled = { false: 'true', true: 'false' }[value.slice(start, end)];
+      // Ignoring double-clicks outside of <textarea>
+      const toggled = end && { false: 'true', true: 'false' }[value.slice(start, end)];
       // FF can't run execCommand on textarea, https://bugzil.la/1220696#c24
       if (toggled && !document.execCommand('insertText', false, toggled)) {
         el.value = value.slice(0, start) + toggled + value.slice(end);

+ 0 - 22
src/options/views/tab-settings/vm-template.vue

@@ -1,22 +0,0 @@
-<template>
-  <section>
-    <h3 v-text="i18n('labelScriptTemplate')"></h3>
-    <setting-text name="scriptTemplate" @save="onSave" has-reset />
-  </section>
-</template>
-
-<script>
-import { showMessage } from '@/common/ui';
-import SettingText from '@/common/ui/setting-text';
-
-export default {
-  components: {
-    SettingText,
-  },
-  methods: {
-    onSave() {
-      showMessage({ text: this.i18n('msgSavedScriptTemplate') });
-    },
-  },
-};
-</script>