Browse Source

feat: show option to allow edits in updated scripts (#1906)

tophf 2 years ago
parent
commit
2a745431d1

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

@@ -172,9 +172,6 @@ editNavSettings:
 editNavValues:
   description: Label of values tab in script editor.
   message: Values
-editReadonly:
-  description: Text of the warning when opening an auto-updated script in the editor.
-  message: To enable editing, disallow updates in the Settings tab and click "Save".
 editValueAll:
   description: Button to show/edit the entire script value storage.
   message: All
@@ -808,6 +805,18 @@ optionUpdate:
     Label of the update section in settings and notification title for script
     updates.
   message: Update
+readonly:
+  description: Text in the editor's header for non-editable scripts.
+  message: Read-only
+readonlyNote:
+  description: Warning when trying to type in the editor.
+  message: Auto-updated script is read-only unless you disable updates or allow editing.
+readonlyOpt:
+  description: Option label in the editor, displayed along with readonlyOptWarn.
+  message: Allow edits
+readonlyOptWarn:
+  description: Text next to readonlyOpt label in the editor.
+  message: (next update will overwite them)
 reloadTab:
   description: Label of action to reload the tab
   message: Reload tab

+ 2 - 1
src/common/ui/code.vue

@@ -70,6 +70,7 @@ import 'codemirror/addon/hint/show-hint';
 import 'codemirror/addon/hint/javascript-hint';
 import 'codemirror/addon/hint/anyword-hint';
 import CodeMirror from 'codemirror';
+import { watchEffect } from 'vue';
 import Tooltip from 'vueleton/lib/tooltip';
 import ToggleButton from '@/common/ui/toggle-button';
 import { debounce, getUniqId, i18n, sendCmdDirectly } from '@/common';
@@ -244,7 +245,7 @@ export default {
       this.placeholders = new Map();
       this.placeholderId = 0;
       maxDisplayLength = cm.options.maxDisplayLength;
-      cm.setOption('readOnly', this.readOnly);
+      watchEffect(() => cm.setOption('readOnly', this.readOnly));
       // these are active only in the code nav tab
       cm.state.commands = Object.assign({
         // call own methods explicitly to strip `cm` parameter passed by CodeMirror

+ 7 - 0
src/common/ui/style/style.css

@@ -410,6 +410,13 @@ body .vl-tooltip {
   }
 }
 
+.scary-switch:checked {
+  accent-color: red;
+  & + span {
+    color: red;
+  }
+}
+
 @media (prefers-color-scheme: dark) {
   input[type="radio"]:not(:checked),
   input[type="checkbox"]:not(:checked) {

+ 2 - 5
src/options/index.js

@@ -10,7 +10,6 @@ import '@/common/ui/favicon';
 import '@/common/ui/style';
 import { store } from './utils';
 import App from './views/app';
-import { watchEffect } from 'vue';
 
 // Same order as getSizes and sizesPrefixRe
 const SIZE_TITLES = [
@@ -56,10 +55,8 @@ function initScript(script, sizes) {
     sizes: str.slice(0, -1).replace(/\x20/g, '\xA0').replace(/[^B]$/gm, '$&B'),
     sizeNum: total,
   };
-  watchEffect(() => {
-    script.$canUpdate = getScriptUpdateUrl(script)
-      && (script.config.shouldUpdate ? 1 : -1 /* manual */);
-  });
+  script.$canUpdate = getScriptUpdateUrl(script)
+    && (script.config.shouldUpdate ? 1 : -1 /* manual */);
   loadScriptIcon(script, store, true);
 }
 

+ 2 - 2
src/options/style.css

@@ -69,8 +69,8 @@ aside {
 .text-red {
   color: red;
 }
-.text-right {
-  text-align: right;
+.text-upper {
+  text-transform: uppercase;
 }
 section {
   padding: 0 0 $tabPadY;

+ 99 - 69
src/options/views/edit/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="edit frame flex flex-col abs-full">
+  <div class="edit frame flex flex-col abs-full" :class="{frozen, readOnly}">
     <div class="edit-header flex mr-1c">
       <nav>
         <div
@@ -10,16 +10,17 @@
         />
       </nav>
       <div class="edit-name text-center ellipsis flex-1">
-        <span class="subtle" v-if="script?.config?.removed" v-text="i18n('headerRecycleBin') + ' / '"></span>
+        <span class="subtle" v-if="script.config.removed" v-text="i18n('headerRecycleBin') + ' / '"/>
         {{scriptName}}
       </div>
-      <div class="edit-hint text-right ellipsis">
+      <p v-if="frozen" class="text-upper text-right text-red" v-text="i18n('readonly')"/>
+      <div v-else class="edit-hint text-right ellipsis">
         <a href="https://violentmonkey.github.io/posts/how-to-edit-scripts-with-your-favorite-editor/"
            target="_blank"
            rel="noopener noreferrer"
            v-text="i18n('editHowToHint')"/>
       </div>
-      <div class="edit-buttons">
+      <div class="mr-1">
         <button v-text="i18n('buttonSave')" @click="save" :disabled="!canSave"
                 :class="{'has-error': errors}" :title="errors"/>
         <button v-text="i18n('buttonSaveClose')" @click="saveClose" :disabled="!canSave"/>
@@ -27,44 +28,46 @@
       </div>
     </div>
 
-    <div v-text="i18n('editReadonly')" class="mx-1 my-1 text-red" v-if="!canEdit"/>
+    <div class="frozen-note mr-2c flex flex-wrap" v-if="note && nav === 'code'">
+      <p v-text="i18n('readonlyNote')"/>
+      <keep-alive>
+        <VMSettingsUpdate class="flex ml-2c" :script="script"/>
+      </keep-alive>
+    </div>
 
     <vm-code
       class="flex-auto"
       :value="code"
-      :readOnly="readOnly || script.$canUpdate === 1"
-      v-on="{ keypress: script.$canUpdate === 1 && (() => (this.canEdit = false)) }"
+      :readOnly="frozen"
+      :title="frozen ? i18n('readonly') : null"
       ref="code"
       v-show="nav === 'code'"
       :active="nav === 'code'"
       :commands="commands"
       @code-dirty="codeDirty = $event"
     />
+    <keep-alive>
     <vm-settings
       class="edit-body"
-      v-show="nav === 'settings'"
-      :readOnly="readOnly"
-      :active="nav === 'settings'"
-      :settings="settings"
-      :value="script"
+      v-if="nav === 'settings'"
+      v-bind="{readOnly, script}"
     />
     <vm-values
       class="edit-body"
-      v-show="nav === 'values'"
-      :readOnly="readOnly"
-      :active="nav === 'values'"
-      :script="script"
+      v-else-if="nav === 'values'"
+      v-bind="{readOnly, script}"
     />
     <vm-externals
       class="flex-auto"
-      v-if="nav === 'externals'"
+      v-else-if="nav === 'externals'"
       :value="script"
     />
     <vm-help
       class="edit-body"
-      v-show="nav === 'help'"
+      v-else-if="nav === 'help'"
       :hotkeys="hotkeys"
     />
+    </keep-alive>
 
     <div v-if="errors" class="errors my-1c">
       <p v-for="e in errors" :key="e" v-text="e" class="text-red"/>
@@ -78,7 +81,7 @@
 <script>
 import {
   browserWindows,
-  debounce, formatByteLength, getScriptName, i18n, isEmpty,
+  debounce, formatByteLength, getScriptName, getScriptUpdateUrl, i18n, isEmpty,
   sendCmdDirectly, trueJoin,
 } from '@/common';
 import { deepCopy, deepEqual, objectPick } from '@/common/object';
@@ -90,6 +93,7 @@ import options from '@/common/options';
 import { getUnloadSentry } from '@/common/router';
 import { store } from '../../utils';
 import VmSettings from './settings';
+import VMSettingsUpdate from './settings-update';
 import VmValues from './values';
 import VmHelp from './help';
 
@@ -103,7 +107,6 @@ const CUSTOM_PROPS = {
   origMatch: true,
   origExcludeMatch: true,
 };
-const fromProp = (val, key) => val ?? CUSTOM_PROPS[key];
 const toProp = val => val !== '' ? val : null; // `null` removes the prop from script object
 const CUSTOM_LISTS = [
   'include',
@@ -111,12 +114,6 @@ const CUSTOM_LISTS = [
   'exclude',
   'excludeMatch',
 ];
-const fromList = list => (
-  list
-    // Adding a new row so the user can click it and type, just like in an empty textarea.
-    ? `${list.join('\n')}${list.length ? '\n' : ''}`
-    : ''
-);
 const toList = text => (
   text.trim()
     ? text.split('\n').map(line => line.trim()).filter(Boolean)
@@ -126,9 +123,7 @@ const CUSTOM_ENUM = [
   INJECT_INTO,
   RUN_AT,
 ];
-const fromEnum = val => val || '';
 const toEnum = val => val || null; // `null` removes the prop from script object
-let savedSettings;
 
 let shouldSavePositionOnSave;
 /** @param {chrome.windows.Window} [wnd] */
@@ -166,12 +161,18 @@ let K_SAVE; // deduced from the current CodeMirror keymap
 const K_PREV_PANEL = 'Alt-PageUp';
 const K_NEXT_PANEL = 'Alt-PageDown';
 const compareString = (a, b) => (a < b ? -1 : a > b);
+/** @param {VMScript.Config} config */
+const collectShouldUpdate = ({ shouldUpdate, _editable }) => (
+  +shouldUpdate && (shouldUpdate + _editable)
+);
+const deepWatchScript = { handler: 'onChange', deep: true };
 
 export default {
   props: ['initial', 'initialCode', 'readOnly'],
   components: {
     VmCode,
     VmSettings,
+    VMSettingsUpdate,
     VmValues,
     VmExternals,
     VmHelp,
@@ -179,12 +180,10 @@ export default {
   data() {
     return {
       nav: 'code',
-      canEdit: true,
       canSave: false,
       script: null,
       code: '',
       codeDirty: false,
-      settings: {},
       commands: {
         save: this.save,
         close: this.close,
@@ -194,19 +193,21 @@ export default {
       },
       hotkeys: null,
       errors: null,
+      frozen: false,
+      note: false,
       urlMatching: 'https://violentmonkey.github.io/api/matching/',
     };
   },
   computed: {
     navItems() {
-      const { meta, props } = this.script || {};
-      const req = meta?.require.length && '@require';
-      const res = !isEmpty(meta?.resources) && '@resource';
+      const { meta, props } = this.script;
+      const req = meta.require.length && '@require';
+      const res = !isEmpty(meta.resources) && '@resource';
       const size = store.storageSize;
       return {
         code: i18n('editNavCode'),
         settings: i18n('editNavSettings'),
-        ...props?.id && {
+        ...props.id && {
           values: i18n('editNavValues') + (size ? ` (${formatByteLength(size)})` : ''),
         },
         ...(req || res) && { externals: [req, res]::trueJoin('/') },
@@ -239,9 +240,13 @@ export default {
         showMessage({ text: `${this.initial.message}\n\n${error}` });
       }
     },
+    codeDirty: 'onDirty',
+    script: 'onScript',
+    'script.config': deepWatchScript,
+    'script.custom': deepWatchScript,
   },
   created() {
-    this.script = this.initial;
+    this.script = deepCopy(this.initial);
     this.toggleUnloadSentry = getUnloadSentry(null, () => {
       this.$refs.code.cm.focus();
     });
@@ -252,26 +257,6 @@ export default {
   async mounted() {
     document.body.classList.add('edit-open');
     store.storageSize = 0;
-    this.nav = 'code';
-    const { custom, config } = this.script;
-    const { noframes } = custom;
-    this.settings = {
-      config: {
-        notifyUpdates: `${config.notifyUpdates ?? ''}`,
-        // Needs to match Vue model type so deepEqual can work properly
-        shouldUpdate: Boolean(config.shouldUpdate),
-      },
-      custom: {
-        // Adding placeholders for any missing values so deepEqual can work properly
-        ...objectPick(custom, Object.keys(CUSTOM_PROPS), fromProp),
-        ...objectPick(custom, CUSTOM_ENUM, fromEnum),
-        ...objectPick(custom, CUSTOM_LISTS, fromList),
-        noframes: noframes == null ? '' : +noframes, // it was boolean in old VM
-      },
-    };
-    savedSettings = deepCopy(this.settings);
-    this.$watch('codeDirty', this.onChange);
-    this.$watch('settings', this.onChange, { deep: true });
     // hotkeys
     {
       const navLabels = Object.values(this.navItems);
@@ -302,19 +287,19 @@ export default {
     async save() {
       if (!this.canSave) return;
       if (shouldSavePositionOnSave) savePosition();
-      const { settings } = this;
-      const { config, custom } = settings;
+      const script = this.script;
+      const { config, custom } = script;
       const { notifyUpdates } = config;
       const { noframes } = custom;
       try {
         const codeComponent = this.$refs.code;
-        const id = this.script?.props?.id;
+        const id = script.props.id;
         const res = await sendCmdDirectly('ParseScript', {
           id,
           code: codeComponent.getRealContent(),
           config: {
-            ...config,
-            notifyUpdates: notifyUpdates ? +notifyUpdates : null,
+            notifyUpdates: notifyUpdates ? +notifyUpdates : null, // 0, 1, null
+            shouldUpdate: collectShouldUpdate(config), // 0, 1, 2
           },
           custom: {
             ...objectPick(custom, Object.keys(CUSTOM_PROPS), toProp),
@@ -329,15 +314,13 @@ export default {
           message: '',
         });
         const newId = res?.where?.id;
-        savedSettings = deepCopy(settings);
         codeComponent.cm.markClean();
-        this.codeDirty = false; // triggers onChange which sets canSave
+        this.codeDirty = false; // triggers onDirty which sets canSave
         this.canSave = false; // ...and set it explicitly in case codeDirty was false
+        this.note = false;
         this.errors = res.errors;
-        if (newId) {
-          this.script = res.update;
-          if (!id) history.replaceState(null, this.scriptName, `${ROUTE_SCRIPTS}/${newId}`);
-        }
+        this.script = res.update; // triggers onScript+onChange to handle the new `meta` and `props`
+        if (newId && !id) history.replaceState(null, this.scriptName, `${ROUTE_SCRIPTS}/${newId}`);
       } catch (err) {
         showConfirmation(`${err.message || err}`, {
           cancel: false,
@@ -366,9 +349,44 @@ export default {
     switchNextPanel() {
       this.switchPanel(1);
     },
-    onChange() {
-      this.canSave = this.codeDirty || !deepEqual(this.settings, savedSettings);
+    onChange(evt) {
+      const { script } = this;
+      const { config } = script;
+      const { removed } = config;
+      const remote = script._remote = !!getScriptUpdateUrl(script);
+      const remoteMode = remote && collectShouldUpdate(config);
+      const frozen = !!(removed || remoteMode === 1 || this.readOnly);
+      this.frozen = frozen;
+      this.note = !removed && (frozen || remoteMode >= 1);
+      if (!removed && evt) this.onDirty();
     },
+    onDirty() {
+      this.canSave = this.codeDirty || !deepEqual(this.script, this.saved);
+    },
+    onScript(script) {
+      const { custom, config } = script;
+      const { shouldUpdate } = config;
+      const { noframes } = custom;
+      // Matching Vue model types, so deepEqual can work properly
+      config._editable = shouldUpdate === 2;
+      config.notifyUpdates == `${config.notifyUpdates ?? ''}`;
+      config.shouldUpdate = !!shouldUpdate;
+      custom.noframes = noframes == null ? '' : +noframes; // it was boolean in old VM
+      // Adding placeholders for any missing values so deepEqual can work properly
+      for (const key in CUSTOM_PROPS) {
+        if (custom[key] == null) custom[key] = CUSTOM_PROPS[key];
+      }
+      for (const key of CUSTOM_ENUM) {
+        if (!custom[key]) custom[key] = '';
+      }
+      for (const key of CUSTOM_LISTS) {
+        const val = custom[key];
+        // Adding a new row so the user can click it and type, just like in an empty textarea.
+        custom[key] = val ? `${val.join('\n')}${val.length ? '\n' : ''}` : '';
+      }
+      this.onChange();
+      if (!config.removed) this.saved = deepCopy(script);
+    }
   },
   beforeUnmount() {
     document.body.classList.remove('edit-open');
@@ -383,6 +401,7 @@ export default {
 
 <style>
 .edit {
+  --border: 1px solid var(--fill-3);
   z-index: 2000;
   &-header {
     position: sticky;
@@ -390,7 +409,7 @@ export default {
     z-index: 1;
     align-items: center;
     justify-content: space-between;
-    border-bottom: 1px solid var(--fill-3);
+    border-bottom: var(--border);
     background: inherit;
   }
   &-name {
@@ -449,6 +468,17 @@ export default {
     border-top: 2px solid red;
     padding: .5em 1em;
   }
+  .frozen-note {
+    background: var(--bg);
+    padding: .5em 1em;
+    border-bottom: var(--border);
+  }
+  &.readOnly &-header button:nth-last-child(n + 2) {
+    display: none;
+  }
+  &.frozen .CodeMirror {
+    background: var(--fill-0-5);
+  }
 }
 
 @media (max-width: 767px) {

+ 44 - 0
src/options/views/edit/settings-update.vue

@@ -0,0 +1,44 @@
+<template>
+  <div>
+    <div class="form-group condensed">
+      <label>
+        <input type="checkbox" v-model="config.shouldUpdate" v-bind="{disabled}">
+        <span v-text="i18n('labelAllowUpdate')"/>
+        <span v-text="i18n('labelNotifyThisUpdated')" class="melt"/>
+      </label>
+      <label class="ml-1 melt" :key="value" v-for="([text, value]) of [
+        [i18n('genericOn'), '1'],
+        [i18n('genericOff'), '0'],
+        [i18n('genericUseGlobal'), ''],
+      ]"><!-- make sure to place the input and span on one line with a space between -->
+        <input type="radio" v-bind="{value, disabled}"
+               v-model="config.notifyUpdates"> <span v-text="text"/>
+      </label>
+    </div>
+    <label>
+      <input type="checkbox" v-model="config._editable" class="scary-switch"
+             :disabled="disabled || !config.shouldUpdate">
+      <span v-text="i18n('readonlyOpt')"/> <span v-text="i18n('readonlyOptWarn')"/>
+    </label>
+  </div>
+</template>
+
+<script>
+export default {
+  props: ['script'],
+  computed: {
+    config() {
+      return this.script.config;
+    },
+    disabled() {
+      return !this.script._remote;
+    },
+  },
+};
+</script>
+
+<style>
+.frozen-note .melt {
+  display: none;
+}
+</style>

+ 11 - 24
src/options/views/edit/settings.vue

@@ -1,20 +1,7 @@
 <template>
   <div class="edit-settings" ref="container">
     <h4 v-text="i18n('editLabelSettings')"></h4>
-    <div class="form-group condensed">
-      <label>
-        <input type="checkbox" v-model="config.shouldUpdate" :disabled="readOnly">
-        <span v-text="i18n('labelAllowUpdate')"></span>
-      </label>
-      <span v-text="i18n('labelNotifyThisUpdated')"/>
-      <label class="ml-1" :key="value" v-for="([text, value]) of [
-        [i18n('genericOn'), '1'],
-        [i18n('genericOff'), '0'],
-        [i18n('genericUseGlobal'), ''],
-      ]"><!-- make sure to place the input and span on one line with a space between -->
-        <input type="radio" :value="value" v-model="config.notifyUpdates" :disabled="readOnly"> <span v-text="text"/>
-      </label>
-    </div>
+    <VMSettingsUpdate v-bind="{script}"/>
     <h4 v-text="i18n('editLabelMeta')"></h4>
     <!-- Using tables to auto-adjust width, which differs substantially between languages -->
     <table>
@@ -102,11 +89,15 @@ import { getScriptHome, i18n } from '@/common';
 import { KNOWN_INJECT_INTO } from '@/common/consts';
 import { objectGet } from '@/common/object';
 import { focusMe } from '@/common/ui';
+import VMSettingsUpdate from './settings-update';
 
 const highlightMetaKeys = str => str.match(/^(.*?)(@[-a-z]+)(.*)/)?.slice(1) || [str, '', ''];
 
 export default {
-  props: ['active', 'settings', 'value', 'readOnly'],
+  props: ['script', 'readOnly'],
+  components: {
+    VMSettingsUpdate,
+  },
   data() {
     return {
       KII: KNOWN_INJECT_INTO,
@@ -114,13 +105,13 @@ export default {
   },
   computed: {
     custom() {
-      return this.settings.custom || {};
+      return this.script.custom;
     },
     config() {
-      return this.settings.config || {};
+      return this.script.config;
     },
     placeholders() {
-      const { value } = this;
+      const value = this.script;
       return {
         name: objectGet(value, 'meta.name'),
         homepageURL: getScriptHome(value),
@@ -145,12 +136,8 @@ export default {
       ];
     },
   },
-  watch: {
-    active(val) {
-      if (val) {
-        focusMe(this.$el);
-      }
-    },
+  activated() {
+    focusMe(this.$el);
   },
 };
 </script>

+ 31 - 36
src/options/views/edit/values.vue

@@ -125,7 +125,7 @@ const fakeSender = () => ({ tab: { id: Math.random() - 2 }, [kFrameId]: 0 });
 const conditionNotEdit = { condition: '!edit' };
 
 export default {
-  props: ['active', 'script', 'readOnly'],
+  props: ['script', 'readOnly'],
   components: {
     Icon,
     VmCode,
@@ -156,42 +156,37 @@ export default {
       keyboardService.setContext('edit', 'selectionEnd' in evt.target);
     });
   },
-  watch: {
-    active(val) {
-      const id = this.script.props.id;
-      if (val) {
-        (this.current ? this.cm : focusedElement)?.focus();
-        sendCmdDirectly('GetValueStore', id, undefined, this.sender = fakeSender()).then(data => {
-          const isFirstTime = !this.values;
-          if (this.setData(data) && isFirstTime && this.keys.length) {
-            this.autofocus(true);
-          }
-          this.loading = false;
-        });
-        this.disposeList = [
-          keyboardService.register('pageup', () => flipPage(this, -1), conditionNotEdit),
-          keyboardService.register('pagedown', () => flipPage(this, 1), conditionNotEdit),
-        ];
-      } else {
-        this.disposeList?.forEach(dispose => dispose());
+  activated() {
+    const id = this.script.props.id;
+    (this.current ? this.cm : focusedElement)?.focus();
+    sendCmdDirectly('GetValueStore', id, undefined, this.sender = fakeSender()).then(data => {
+      const isFirstTime = !this.values;
+      if (this.setData(data) && isFirstTime && this.keys.length) {
+        this.autofocus(true);
       }
-      // toggle storage watcher
-      if (val) {
-        const fn = this.onStorageChanged;
-        const bg = browser.extension.getBackgroundPage();
-        this[WATCH_STORAGE] = browser.runtime.connect({
-          name: WATCH_STORAGE + JSON.stringify({
-            cfg: { value: id },
-            id: bg?.[WATCH_STORAGE](fn),
-            tabId: this.sender.tab.id,
-          }),
-        });
-        if (!bg) this[WATCH_STORAGE].onMessage.addListener(fn);
-      } else {
-        this[WATCH_STORAGE]?.disconnect();
-        this[WATCH_STORAGE] = null;
-      }
-    },
+      this.loading = false;
+    });
+    this.disposeList = [
+      keyboardService.register('pageup', () => flipPage(this, -1), conditionNotEdit),
+      keyboardService.register('pagedown', () => flipPage(this, 1), conditionNotEdit),
+    ];
+    const fn = this.onStorageChanged;
+    const bg = browser.extension.getBackgroundPage();
+    this[WATCH_STORAGE] = browser.runtime.connect({
+      name: WATCH_STORAGE + JSON.stringify({
+        cfg: { value: id },
+        id: bg?.[WATCH_STORAGE](fn),
+        tabId: this.sender.tab.id,
+      }),
+    });
+    if (!bg) this[WATCH_STORAGE].onMessage.addListener(fn);
+  },
+  deactivated() {
+    this.disposeList?.forEach(dispose => dispose());
+    this[WATCH_STORAGE]?.disconnect();
+    this[WATCH_STORAGE] = null;
+  },
+  watch: {
     current(val, oldVal) {
       if (val) {
         focusedElement = getActiveElement();

+ 2 - 1
src/types.d.ts

@@ -143,7 +143,8 @@ declare namespace VMScript {
   type Config = {
     enabled: NumBool;
     removed: NumBool;
-    shouldUpdate: NumBool;
+    /** 2 = allow updates and local edits */
+    shouldUpdate: NumBool | 2;
     notifyUpdates?: NumBoolNull;
   }
   type Custom = {