Browse Source

refactor: SFC-setup in edit/*

tophf 2 years ago
parent
commit
72991071c9

+ 5 - 0
scripts/webpack.conf.js

@@ -118,6 +118,11 @@ const modify = (page, entry, init) => modifyWebpackConfig(
       if (typeof rule.test?.test === 'function' && rule.test.test('file.js')) {
         rule.exclude = file => /node_modules/.test(file) && !/vueleton|@vue[/\\]shared/.test(file);
       }
+      const vueOpts = rule.test?.source.includes('.vue') && rule.options;
+      if (vueOpts) {
+        const arr = vueOpts.babelParserPlugins || (vueOpts.babelParserPlugins = []);
+        if (!arr.includes('functionBind')) arr.push('functionBind');
+      }
     });
     if (!entry) init = page;
     if (init) init(config);

+ 1 - 1
src/common/index.js

@@ -79,7 +79,7 @@ const COMMANDS_WITH_SRC = [
   'SetPopup',
 */
 ];
-const getBgPage = () => browser.extension.getBackgroundPage?.();
+export const getBgPage = () => browser.extension.getBackgroundPage?.();
 
 /**
  * Sends the command+data directly so it's synchronous and faster than sendCmd thanks to deepCopy.

+ 4 - 4
src/options/views/edit/help.vue

@@ -15,10 +15,10 @@
   </div>
 </template>
 
-<script>
-export default {
-  props: ['hotkeys'],
-};
+<script setup>
+defineProps({
+  hotkeys: Array
+});
 </script>
 
 <style>

+ 237 - 249
src/options/views/edit/index.vue

@@ -13,7 +13,8 @@
         <span class="subtle" v-if="script.config.removed" v-text="i18n('headerRecycleBin') + ' / '"/>
         {{scriptName}}
       </div>
-      <p v-if="frozen" class="text-upper text-right text-red" v-text="i18n('readonly')"/>
+      <p v-if="frozen && nav === 'code'" v-text="i18n('readonly')"
+         class="text-upper text-right text-red"/>
       <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"
@@ -46,7 +47,7 @@
       class="flex-auto"
       :value="code"
       :readOnly="frozen"
-      ref="code"
+      ref="$code"
       v-show="nav === 'code'"
       :active="nav === 'code'"
       :commands="commands"
@@ -84,7 +85,8 @@
   </div>
 </template>
 
-<script>
+<script setup>
+import { computed, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue';
 import {
   browserWindows,
   debounce, formatByteLength, getScriptName, getScriptUpdateUrl, i18n, isEmpty,
@@ -103,6 +105,74 @@ import VMSettingsUpdate from './settings-update';
 import VmValues from './values';
 import VmHelp from './help';
 
+let CM;
+let $codeComp;
+let disposeList;
+let K_SAVE; // deduced from the current CodeMirror keymap
+let savedCopy;
+let shouldSavePositionOnSave;
+let toggleUnloadSentry;
+
+const emit = defineEmits(['close']);
+const props = defineProps({
+  /** @type {VMScript} */
+  initial: Object,
+  initialCode: String,
+  readOnly: Boolean,
+});
+
+const $code = ref();
+const nav = ref('code');
+const canSave = ref(false);
+const script = ref();
+const code = ref('');
+const codeDirty = ref(false);
+const commands = {
+  save,
+  close,
+  showHelp: () => { nav.value = 'help'; },
+};
+const hotkeys = ref();
+const errors = ref();
+const fatal = ref();
+const frozen = ref(false);
+const frozenNote = ref(false);
+const urlMatching = ref('https://violentmonkey.github.io/api/matching/');
+
+const navItems = computed(() => {
+  const { meta, props: { id } } = script.value;
+  const req = meta.require.length && '@require';
+  const res = !isEmpty(meta.resources) && '@resource';
+  const size = store.storageSize;
+  return {
+    code: i18n('editNavCode'),
+    settings: i18n('editNavSettings'),
+    ...id && {
+      values: i18n('editNavValues') + (size ? ` (${formatByteLength(size)})` : ''),
+    },
+    ...(req || res) && { externals: [req, res]::trueJoin('/') },
+    help: '?',
+  };
+});
+const scriptName = computed(() => (store.title = getScriptName(script.value)));
+
+watch(nav, val => {
+  keyboardService.setContext('tabCode', val === 'code');
+  if (val === 'code') nextTick(() => CM.focus());
+});
+watch(canSave, val => {
+  toggleUnloadSentry(val);
+  keyboardService.setContext('canSave', val);
+});
+// usually errors for resources
+watch(() => props.initial.error, error => {
+  if (error) {
+    showMessage({ text: `${props.initial.message}\n\n${error}` });
+  }
+});
+watch(codeDirty, onDirty);
+watch(script, onScript);
+
 const CUSTOM_PROPS = {
   name: '',
   homepageURL: '',
@@ -130,10 +200,168 @@ const CUSTOM_ENUM = [
   RUN_AT,
 ];
 const toEnum = val => val || null; // `null` removes the prop from script object
+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)
+);
 
-let shouldSavePositionOnSave;
+{
+  // The eslint rule is bugged as this is a block scope, not a global scope.
+  const src = props.initial; // eslint-disable-line vue/no-setup-props-destructure
+  code.value = props.initialCode; // eslint-disable-line vue/no-setup-props-destructure
+  script.value = deepCopy(src);
+  watch(() => script.value.config, onChange, { deep: true });
+  watch(() => script.value.custom, onChange, { deep: true });
+}
+
+onMounted(() => {
+  $codeComp = $code.value;
+  CM = $codeComp.cm;
+  toggleUnloadSentry = getUnloadSentry(null, () => CM.focus());
+  if (options.get('editorWindow') && global.history.length === 1) {
+    browser.windows?.getCurrent({ populate: true }).then(setupSavePosition);
+  }
+  store.storageSize = 0;
+  // hotkeys
+  const navLabels = Object.values(navItems.value);
+  const hk = hotkeys.value = [
+    [K_PREV_PANEL, ` ${navLabels.join(' < ')}`],
+    [K_NEXT_PANEL, ` ${navLabels.join(' > ')}`],
+    ...Object.entries($codeComp.expandKeyMap())
+    .sort((a, b) => compareString(a[1], b[1]) || compareString(a[0], b[0])),
+  ];
+  K_SAVE = hk.find(([, cmd]) => cmd === 'save')?.[0];
+  if (!K_SAVE) {
+    K_SAVE = 'Ctrl-S';
+    hk.unshift([K_SAVE, 'save']);
+  }
+});
+
+onActivated(() => {
+  document.body.classList.add('edit-open');
+  disposeList = [
+    keyboardService.register('a-pageup', switchPrevPanel),
+    keyboardService.register('a-pagedown', switchNextPanel),
+    keyboardService.register(K_SAVE.replace(/(?:Ctrl|Cmd)-/i, 'ctrlcmd-'), save),
+    keyboardService.register('escape', () => { nav.value = 'code'; }, {
+      condition: '!tabCode',
+    }),
+  ];
+  store.title = scriptName.value;
+});
+
+onDeactivated(() => {
+  document.body.classList.remove('edit-open');
+  store.title = null;
+  toggleUnloadSentry(false);
+  disposeList?.forEach(dispose => dispose());
+});
+
+async function save() {
+  if (!canSave.value) return;
+  if (shouldSavePositionOnSave) savePosition();
+  const scr = script.value;
+  const { config, custom } = scr;
+  const { notifyUpdates } = config;
+  const { noframes } = custom;
+  try {
+    const id = scr.props.id;
+    const res = await sendCmdDirectly('ParseScript', {
+      id,
+      code: $codeComp.getRealContent(),
+      config: {
+        notifyUpdates: notifyUpdates ? +notifyUpdates : null, // 0, 1, null
+        shouldUpdate: collectShouldUpdate(config), // 0, 1, 2
+      },
+      custom: {
+        ...objectPick(custom, Object.keys(CUSTOM_PROPS), toProp),
+        ...objectPick(custom, CUSTOM_LISTS, toList),
+        ...objectPick(custom, CUSTOM_ENUM, toEnum),
+        noframes: noframes ? +noframes : null,
+      },
+      // User created scripts MUST be marked `isNew` so that
+      // the backend is able to check namespace conflicts,
+      // otherwise the script with same namespace will be overridden
+      isNew: !id,
+      message: '',
+    });
+    const newId = res?.where?.id;
+    CM.markClean();
+    codeDirty.value = false; // triggers onDirty which sets canSave
+    canSave.value = false; // ...and set it explicitly in case codeDirty was false
+    frozenNote.value = false;
+    errors.value = res.errors;
+    script.value = res.update; // triggers onScript+onChange to handle the new `meta` and `props`
+    if (newId && !id) history.replaceState(null, scriptName.value, `${ROUTE_SCRIPTS}/${newId}`);
+    fatal.value = null;
+  } catch (err) {
+    fatal.value = err.message.split('\n');
+  }
+}
+function close(cm) {
+  if (cm && nav.value !== 'code') {
+    nav.value = 'code';
+  } else {
+    emit('close');
+    // FF doesn't emit `blur` when CodeMirror's textarea is removed
+    if (IS_FIREFOX) document.activeElement?.blur();
+  }
+}
+function saveClose() {
+  save().then(close);
+}
+function switchPanel(step) {
+  const keys = Object.keys(navItems.value);
+  nav.value = keys[(keys.indexOf(nav.value) + step + keys.length) % keys.length];
+}
+function switchPrevPanel() {
+  switchPanel(-1);
+}
+function switchNextPanel() {
+  switchPanel(1);
+}
+function onChange(evt) {
+  const scr = script.value;
+  const { config } = scr;
+  const { removed } = config;
+  const remote = scr._remote = !!getScriptUpdateUrl(scr);
+  const remoteMode = remote && collectShouldUpdate(config);
+  const fz = !!(removed || remoteMode === 1 || props.readOnly);
+  frozen.value = fz;
+  frozenNote.value = !removed && (fz || remoteMode >= 1);
+  if (!removed && evt) onDirty();
+}
+function onDirty() {
+  canSave.value = codeDirty.value || !deepEqual(script.value, savedCopy);
+}
+function onScript(scr) {
+  const { custom, config } = scr;
+  const { shouldUpdate } = config;
+  // Matching Vue model types, so deepEqual can work properly
+  config._editable = shouldUpdate === 2;
+  config.shouldUpdate = !!shouldUpdate;
+  config.notifyUpdates = nullBool2string(config.notifyUpdates);
+  custom.noframes = nullBool2string(custom.noframes);
+  // 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' : ''}` : '';
+  }
+  onChange();
+  if (!config.removed) savedCopy = deepCopy(scr);
+}
 /** @param {chrome.windows.Window} [wnd] */
-const savePosition = async wnd => {
+async function savePosition(wnd) {
   if (options.get('editorWindow')) {
     if (!wnd) wnd = await browserWindows?.getCurrent() || {};
     /* chrome.windows API can't set both the state and coords, so we have to choose:
@@ -145,9 +373,10 @@ const savePosition = async wnd => {
       options.set('editorWindowPos', objectPick(wnd, ['left', 'top', 'width', 'height']));
     }
   }
-};
+}
+
 /** @param {chrome.windows.Window} _ */
-const setupSavePosition = ({ id: curWndId, tabs }) => {
+function setupSavePosition({ id: curWndId, tabs }) {
   if (tabs.length === 1) {
     const { onBoundsChanged } = chrome.windows;
     if (onBoundsChanged) {
@@ -161,248 +390,7 @@ const setupSavePosition = ({ id: curWndId, tabs }) => {
       shouldSavePositionOnSave = true;
     }
   }
-};
-
-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,
-  },
-  data() {
-    return {
-      nav: 'code',
-      canSave: false,
-      script: null,
-      code: '',
-      codeDirty: false,
-      commands: {
-        save: this.save,
-        close: this.close,
-        showHelp: () => {
-          this.nav = 'help';
-        },
-      },
-      hotkeys: null,
-      errors: null,
-      fatal: null,
-      frozen: false,
-      frozenNote: 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 size = store.storageSize;
-      return {
-        code: i18n('editNavCode'),
-        settings: i18n('editNavSettings'),
-        ...props.id && {
-          values: i18n('editNavValues') + (size ? ` (${formatByteLength(size)})` : ''),
-        },
-        ...(req || res) && { externals: [req, res]::trueJoin('/') },
-        help: '?',
-      };
-    },
-    scriptName() {
-      const { script } = this;
-      const scriptName = script?.meta && getScriptName(script);
-      store.title = scriptName;
-      return scriptName;
-    },
-  },
-  watch: {
-    nav(val) {
-      keyboardService.setContext('tabCode', val === 'code');
-      if (val === 'code') {
-        this.$nextTick(() => {
-          this.$refs.code.cm.focus();
-        });
-      }
-    },
-    canSave(val) {
-      this.toggleUnloadSentry(val);
-      keyboardService.setContext('canSave', val);
-    },
-    // usually errors for resources
-    'initial.error'(error) {
-      if (error) {
-        showMessage({ text: `${this.initial.message}\n\n${error}` });
-      }
-    },
-    codeDirty: 'onDirty',
-    script: 'onScript',
-    'script.config': deepWatchScript,
-    'script.custom': deepWatchScript,
-  },
-  created() {
-    this.script = deepCopy(this.initial);
-    this.toggleUnloadSentry = getUnloadSentry(null, () => {
-      this.$refs.code.cm.focus();
-    });
-    if (options.get('editorWindow') && global.history.length === 1) {
-      browser.windows?.getCurrent({ populate: true }).then(setupSavePosition);
-    }
-  },
-  async mounted() {
-    document.body.classList.add('edit-open');
-    store.storageSize = 0;
-    // hotkeys
-    {
-      const navLabels = Object.values(this.navItems);
-      const hotkeys = [
-        [K_PREV_PANEL, ` ${navLabels.join(' < ')}`],
-        [K_NEXT_PANEL, ` ${navLabels.join(' > ')}`],
-        ...Object.entries(this.$refs.code.expandKeyMap())
-        .sort((a, b) => compareString(a[1], b[1]) || compareString(a[0], b[0])),
-      ];
-      K_SAVE = hotkeys.find(([, cmd]) => cmd === 'save')?.[0];
-      if (!K_SAVE) {
-        K_SAVE = 'Ctrl-S';
-        hotkeys.unshift([K_SAVE, 'save']);
-      }
-      this.hotkeys = hotkeys;
-    }
-    this.disposeList = [
-      keyboardService.register('a-pageup', this.switchPrevPanel),
-      keyboardService.register('a-pagedown', this.switchNextPanel),
-      keyboardService.register(K_SAVE.replace(/(?:Ctrl|Cmd)-/i, 'ctrlcmd-'), this.save),
-      keyboardService.register('escape', () => { this.nav = 'code'; }, {
-        condition: '!tabCode',
-      }),
-    ];
-    this.code = this.initialCode;
-  },
-  methods: {
-    async save() {
-      if (!this.canSave) return;
-      if (shouldSavePositionOnSave) savePosition();
-      const script = this.script;
-      const { config, custom } = script;
-      const { notifyUpdates } = config;
-      const { noframes } = custom;
-      let fatal;
-      try {
-        const codeComponent = this.$refs.code;
-        const id = script.props.id;
-        const res = await sendCmdDirectly('ParseScript', {
-          id,
-          code: codeComponent.getRealContent(),
-          config: {
-            notifyUpdates: notifyUpdates ? +notifyUpdates : null, // 0, 1, null
-            shouldUpdate: collectShouldUpdate(config), // 0, 1, 2
-          },
-          custom: {
-            ...objectPick(custom, Object.keys(CUSTOM_PROPS), toProp),
-            ...objectPick(custom, CUSTOM_LISTS, toList),
-            ...objectPick(custom, CUSTOM_ENUM, toEnum),
-            noframes: noframes ? +noframes : null,
-          },
-          // User created scripts MUST be marked `isNew` so that
-          // the backend is able to check namespace conflicts,
-          // otherwise the script with same namespace will be overridden
-          isNew: !id,
-          message: '',
-        });
-        const newId = res?.where?.id;
-        codeComponent.cm.markClean();
-        this.codeDirty = false; // triggers onDirty which sets canSave
-        this.canSave = false; // ...and set it explicitly in case codeDirty was false
-        this.frozenNote = false;
-        this.errors = res.errors;
-        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) {
-        fatal = err.message.split('\n');
-      }
-      this.fatal = fatal;
-    },
-    close(cm) {
-      if (cm && this.nav !== 'code') {
-        this.nav = 'code';
-      } else {
-        this.$emit('close');
-        // FF doesn't emit `blur` when CodeMirror's textarea is removed
-        if (IS_FIREFOX) document.activeElement?.blur();
-      }
-    },
-    saveClose() {
-      this.save().then(this.close);
-    },
-    switchPanel(step) {
-      const keys = Object.keys(this.navItems);
-      this.nav = keys[(keys.indexOf(this.nav) + step + keys.length) % keys.length];
-    },
-    switchPrevPanel() {
-      this.switchPanel(-1);
-    },
-    switchNextPanel() {
-      this.switchPanel(1);
-    },
-    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.frozenNote = !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;
-      // Matching Vue model types, so deepEqual can work properly
-      config._editable = shouldUpdate === 2;
-      config.shouldUpdate = !!shouldUpdate;
-      config.notifyUpdates = nullBool2string(config.notifyUpdates);
-      custom.noframes = nullBool2string(custom.noframes);
-      // 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');
-    store.title = null;
-    this.toggleUnloadSentry(false);
-    this.disposeList?.forEach(dispose => {
-      dispose();
-    });
-  },
-};
+}
 </script>
 
 <style>

+ 8 - 12
src/options/views/edit/settings-update.vue

@@ -23,18 +23,14 @@
   </div>
 </template>
 
-<script>
-export default {
-  props: ['script'],
-  computed: {
-    config() {
-      return this.script.config;
-    },
-    disabled() {
-      return !this.script._remote;
-    },
-  },
-};
+<script setup>
+import { computed } from 'vue';
+
+const props = defineProps({
+  script: Object,
+});
+const config = computed(() => props.script.config);
+const disabled = computed(() => !props.script._remote);
 </script>
 
 <style>

+ 40 - 50
src/options/views/edit/settings.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="edit-settings" ref="container">
+  <div class="edit-settings" ref="$el">
     <h4 v-text="i18n('editLabelSettings')"></h4>
     <VMSettingsUpdate v-bind="{script}"/>
     <h4 v-text="i18n('editLabelMeta')"></h4>
@@ -84,62 +84,52 @@
   </div>
 </template>
 
-<script>
+<script setup>
+import { computed, onActivated, ref, shallowRef } from 'vue';
 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 kDownloadURL = 'downloadURL';
+const kHomepageURL = 'homepageURL';
+const kUpdateURL = 'updateURL';
+
+const props = defineProps({
+  script: Object,
+  readOnly: Boolean,
+});
+const $el = ref();
+const KII = shallowRef(KNOWN_INJECT_INTO);
+
 const highlightMetaKeys = str => str.match(/^(.*?)(@[-a-z]+)(.*)/)?.slice(1) || [str, '', ''];
+const custom = computed(() => props.script.custom);
+const placeholders = computed(() => {
+  const { script } = props;
+  const { meta } = script;
+  return {
+    name: meta.name,
+    [kHomepageURL]: getScriptHome(script),
+    [kUpdateURL]: meta[kUpdateURL] || i18n('hintUseDownloadURL'),
+    [kDownloadURL]: meta[kDownloadURL] || script.custom.lastInstallURL,
+  };
+});
+const textInputs = [
+  ['name', i18n('labelName')],
+  [kHomepageURL, i18n('labelHomepageURL')],
+  [kUpdateURL, i18n('labelUpdateURL')],
+  [kDownloadURL, i18n('labelDownloadURL')],
+];
+const textAreas = [
+  ['include', 'origInclude', ...highlightMetaKeys(i18n('labelInclude'))],
+  ['match', 'origMatch', ...highlightMetaKeys(i18n('labelMatch'))],
+  ['exclude', 'origExclude', ...highlightMetaKeys(i18n('labelExclude'))],
+  ['excludeMatch', 'origExcludeMatch', ...highlightMetaKeys(i18n('labelExcludeMatch'))],
+];
 
-export default {
-  props: ['script', 'readOnly'],
-  components: {
-    VMSettingsUpdate,
-  },
-  data() {
-    return {
-      KII: KNOWN_INJECT_INTO,
-    };
-  },
-  computed: {
-    custom() {
-      return this.script.custom;
-    },
-    config() {
-      return this.script.config;
-    },
-    placeholders() {
-      const value = this.script;
-      return {
-        name: objectGet(value, 'meta.name'),
-        homepageURL: getScriptHome(value),
-        updateURL: objectGet(value, 'meta.updateURL') || i18n('hintUseDownloadURL'),
-        downloadURL: objectGet(value, 'meta.downloadURL') || objectGet(value, 'custom.lastInstallURL'),
-      };
-    },
-    textInputs() {
-      return [
-        ['name', i18n('labelName')],
-        ['homepageURL', i18n('labelHomepageURL')],
-        ['updateURL', i18n('labelUpdateURL')],
-        ['downloadURL', i18n('labelDownloadURL')],
-      ];
-    },
-    textAreas() {
-      return [
-        ['include', 'origInclude', ...highlightMetaKeys(i18n('labelInclude'))],
-        ['match', 'origMatch', ...highlightMetaKeys(i18n('labelMatch'))],
-        ['exclude', 'origExclude', ...highlightMetaKeys(i18n('labelExclude'))],
-        ['excludeMatch', 'origExcludeMatch', ...highlightMetaKeys(i18n('labelExcludeMatch'))],
-      ];
-    },
-  },
-  activated() {
-    focusMe(this.$el);
-  },
-};
+onActivated(() => {
+  focusMe($el.value);
+});
 </script>
 
 <style>

+ 279 - 285
src/options/views/edit/values.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="edit-values" ref="container" :data-editing="current && ''">
+  <div class="edit-values" ref="$el" :data-editing="current && ''">
     <div class="mb-1">
       <button @click="onNew" v-if="!readOnly">+</button>
       <div class="inline-block ml-2" v-if="totalPages > 1">
@@ -22,7 +22,7 @@
          @keydown.down.exact="onUpDown"
          @keydown.up.exact="onUpDown">
       <a
-        ref="editAll"
+        ref="$editAll"
         class="edit-values-row flex"
         @click="onEditAll" tabindex="0" v-text="i18n('editValueAllHint')"/>
       <div
@@ -71,7 +71,7 @@
       <label v-show="!current.isAll">
         <span v-text="i18n('valueLabelKey')"/>
         <input type="text" v-model="current.key" :readOnly="!current.isNew || readOnly"
-               ref="key"
+               ref="$key"
                spellcheck="false"
                @keydown.esc.exact.stop="onCancel">
       </label>
@@ -80,7 +80,7 @@
         <!-- TODO: use CodeMirror in json mode -->
         <vm-code
           :value="current.value"
-          ref="value"
+          ref="$value"
           class="h-100 mt-1"
           mode="application/json"
           :readOnly="readOnly"
@@ -92,8 +92,9 @@
   </div>
 </template>
 
-<script>
-import { dumpScriptValue, formatByteLength, isEmpty, sendCmdDirectly } from '@/common';
+<script setup>
+import { computed, nextTick, onActivated, onDeactivated, ref, watch } from 'vue';
+import { dumpScriptValue, formatByteLength, getBgPage, isEmpty, sendCmdDirectly } from '@/common';
 import { handleTabNavigation, keyboardService } from '@/common/keyboard';
 import { deepCopy, deepEqual, mapEntry } from '@/common/object';
 import { WATCH_STORAGE } from '@/common/consts';
@@ -102,10 +103,24 @@ import Icon from '@/common/ui/icon';
 import { showMessage } from '@/common/ui';
 import { store } from '../../utils';
 
+const props = defineProps({
+  /** @type {VMScript} */
+  script: Object,
+  readOnly: Boolean,
+});
+const $el = ref();
+const $editAll = ref();
+const $key = ref();
+const $value = ref();
+const current = ref();
+const loading = ref(true);
+const page = ref();
+const values = ref();
+const trash = ref();
+
 const PAGE_SIZE = 25;
 const MAX_LENGTH = 1024;
 const MAX_JSON_DURATION = 10; // ms
-let focusedElement;
 const currentObservables = { error: '', dirty: false };
 const cutLength = s => (s.length > MAX_LENGTH ? s.slice(0, MAX_LENGTH) : s);
 const reparseJson = (str) => {
@@ -117,293 +132,272 @@ const reparseJson = (str) => {
   }
 };
 const getActiveElement = () => document.activeElement;
-const flipPage = (vm, dir) => {
-  vm.page = Math.max(1, Math.min(vm.totalPages, vm.page + dir));
-};
 /** Uses a negative tabId which is recognized in bg::values.js */
 const fakeSender = () => ({ tab: { id: Math.random() - 2 }, [kFrameId]: 0 });
 const conditionNotEdit = { condition: '!edit' };
+const onFocus = evt => keyboardService.setContext('edit', 'selectionEnd' in evt.target);
 
-export default {
-  props: ['script', 'readOnly'],
-  components: {
-    Icon,
-    VmCode,
-  },
-  data() {
-    return {
-      current: null,
-      loading: true,
-      page: null,
-      values: null,
-      trash: null,
-    };
-  },
-  computed: {
-    keys() {
-      return Object.keys(this.values || {}).sort();
-    },
-    totalPages() {
-      return Math.ceil(this.keys.length / PAGE_SIZE);
-    },
-    pageKeys() {
-      const offset = PAGE_SIZE * (this.page - 1);
-      return this.keys.slice(offset, offset + PAGE_SIZE);
-    },
-  },
-  mounted() {
-    this.$refs.container::addEventListener('focusin', evt => {
-      keyboardService.setContext('edit', 'selectionEnd' in evt.target);
-    });
-  },
-  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);
+const keys = computed(() => Object.keys(values.value || {}).sort());
+const totalPages = computed(() => Math.ceil(keys.value.length / PAGE_SIZE));
+const pageKeys = computed(() => {
+  const offset = PAGE_SIZE * (page.value - 1);
+  return keys.value.slice(offset, offset + PAGE_SIZE);
+});
+
+let cm;
+let disposeList;
+let focusedElement;
+let sender;
+let storageSentry;
+
+onActivated(() => {
+  const root = $el.value;
+  const { id } = props.script.props;
+  const bg = getBgPage();
+  root::addEventListener('focusin', onFocus);
+  (current.value ? cm : focusedElement)?.focus();
+  sendCmdDirectly('GetValueStore', id, undefined, sender = fakeSender()).then(data => {
+    const isFirstTime = !values.value; // DANGER! saving prior to calling setData
+    if (setData(data) && isFirstTime && keys.value.length) {
+      autofocus(true);
+    }
+    loading.value = false;
+  });
+  disposeList = [
+    () => root::removeEventListener('focusin', onFocus),
+    keyboardService.register('pageup', () => flipPage(-1), conditionNotEdit),
+    keyboardService.register('pagedown', () => flipPage(1), conditionNotEdit),
+  ];
+  storageSentry = chrome.runtime.connect({
+    name: WATCH_STORAGE + JSON.stringify({
+      cfg: { value: id },
+      id: bg?.[WATCH_STORAGE](onStorageChanged),
+      tabId: sender.tab.id,
+    }),
+  });
+  if (!bg) storageSentry.onMessage.addListener(onStorageChanged);
+});
+
+onDeactivated(() => {
+  disposeList?.forEach(dispose => dispose());
+  storageSentry?.disconnect();
+  disposeList = storageSentry = null;
+});
+
+watch(current, (val, oldVal) => {
+  if (val) {
+    focusedElement = getActiveElement();
+    nextTick(() => {
+      const vmCode = $value.value;
+      cm = vmCode.cm;
+      if (oldVal) {
+        vmCode.updateValue(val.value); // focuses CM, which we may override in isNew below
+      }
+      if (val.isNew) {
+        const el = $key.value;
+        el.setSelectionRange(0, 0);
+        el.focus();
+      } else {
+        cm.setCursor(0, 0);
+        cm.focus();
       }
-      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,
-      }),
+  } else if (oldVal) {
+    focusedElement?.focus();
+  }
+});
+
+watch(page, () => {
+  focusedElement = null;
+  autofocus();
+});
+
+function autofocus(andClick) {
+  nextTick(() => {
+    $editAll.value[andClick ? 'click' : 'focus']();
+  });
+}
+function flipPage(dir) {
+  page.value = Math.max(1, Math.min(totalPages.value, page.value + dir));
+}
+function getLength(key, raw) {
+  // Showing length as key+val not "key" + : + "raw" to avoid confusing users
+  const len = key.length + (values.value[key] || raw).length - 1;
+  return len < 10_000 ? len : formatByteLength(len);
+}
+function getValue(key, sliced, raw) {
+  let value = values.value[key] || raw;
+  const type = value[0];
+  value = value.slice(1);
+  if (type === 's') value = JSON.stringify(value);
+  else if (!sliced) value = reparseJson(value);
+  return sliced ? cutLength(value) : value;
+}
+function getValueAll() {
+  return `{\n  ${
+    keys.value
+    .map(key => `${JSON.stringify(key)}: ${getValue(key)}`)
+    .join(',\n')
+    .replace(/\n/g, '\n  ') // also handles nested linebreaks inside objects/arrays
+  }\n}`;
+}
+function setData(data = {}) {
+  if (!deepEqual(values.value, data)) {
+    values.value = data;
+    page.value = Math.min(page.value, totalPages.value) || 1;
+    calcSize();
+    return true;
+  }
+}
+function calcSize() {
+  store.storageSize = keys.value.reduce((sum, key) => sum
+    + key.length + 4 + values.value[key].length + 2, 2);
+}
+async function updateValue({
+  key,
+  jsonValue,
+  rawValue = dumpScriptValue(jsonValue) || '',
+}) {
+  const { id } = props.script.props;
+  await sendCmdDirectly('UpdateValue', { id, key, raw: rawValue }, undefined, sender);
+  if (rawValue) {
+    values.value[key] = rawValue;
+  } else {
+    delete values.value[key];
+  }
+  calcSize();
+}
+
+function onNew() {
+  current.value = {
+    isNew: true,
+    key: '',
+    value: '',
+    ...currentObservables,
+  };
+}
+async function onRemove(key) {
+  updateValue({ key });
+  (trash.value || (trash.value = {}))[key + Math.random()] = {
+    key,
+    rawValue: values.value[key],
+    cut: getValue(key, true),
+    len: getLength(key),
+  };
+  if (current.value?.key === key) {
+    current.value = null;
+  }
+}
+function onRestore(trashKey) {
+  const obj = trash.value;
+  const entry = obj[trashKey];
+  delete obj[trashKey];
+  if (isEmpty(obj)) trash.value = null;
+  updateValue(entry);
+}
+function onEdit(key) {
+  current.value = {
+    key,
+    value: getValue(key),
+    ...currentObservables,
+  };
+}
+function onEditAll() {
+  current.value = {
+    isAll: true,
+    value: getValueAll(),
+    ...currentObservables,
+  };
+}
+async function onSave(buttonIndex) {
+  const cur = current.value;
+  if (cur.jsonPaused) {
+    cur.jsonPaused = false;
+    onChange();
+  }
+  if (cur.error) {
+    const pos = cur.errorPos;
+    cm.setSelection(pos, { line: pos.line, ch: pos.ch + 1 });
+    cm.focus();
+    showMessage({ text: cur.error });
+    return;
+  }
+  if (buttonIndex === 1) {
+    cm.markClean();
+    cur.dirty = false;
+  } else {
+    cur.value = null;
+  }
+  if (cur.isAll) {
+    await sendCmdDirectly('SetValueStores', {
+      [props.script.props.id]: cur.jsonValue::mapEntry(val => dumpScriptValue(val) || ''),
     });
-    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();
-        this.$nextTick(() => {
-          const refs = this.$refs;
-          const vmCode = refs.value;
-          const { cm } = vmCode;
-          this.cm = cm;
-          if (oldVal) {
-            vmCode.updateValue(val.value); // focuses CM, which we may override in isNew below
-          }
-          if (val.isNew) {
-            const el = refs.key;
-            el.setSelectionRange(0, 0);
-            el.focus();
-          } else {
-            cm.setCursor(0, 0);
-            cm.focus();
-          }
-        });
-      } else if (oldVal) {
-        focusedElement?.focus();
-      }
-    },
-    page() {
-      focusedElement = null;
-      this.autofocus();
-    },
-  },
-  methods: {
-    autofocus(andClick) {
-      this.$nextTick(() => {
-        this.$refs.editAll[andClick ? 'click' : 'focus']();
-      });
-    },
-    getLength(key, raw) {
-      // Showing length as key+val not "key" + : + "raw" to avoid confusing users
-      const len = key.length + (this.values[key] || raw).length - 1;
-      return len < 10_000 ? len : formatByteLength(len);
-    },
-    getValue(key, sliced, raw) {
-      let value = this.values[key] || raw;
-      const type = value[0];
-      value = value.slice(1);
-      if (type === 's') value = JSON.stringify(value);
-      else if (!sliced) value = reparseJson(value);
-      return sliced ? cutLength(value) : value;
-    },
-    getValueAll() {
-      return `{\n  ${
-        this.keys
-        .map(key => `${JSON.stringify(key)}: ${this.getValue(key)}`)
-        .join(',\n')
-        .replace(/\n/g, '\n  ') // also handles nested linebreaks inside objects/arrays
-      }\n}`;
-    },
-    setData(values = {}) {
-      if (!deepEqual(this.values, values)) {
-        this.values = values;
-        this.page = Math.min(this.page, this.totalPages) || 1;
-        this.calcSize();
-        return true;
-      }
-    },
-    calcSize() {
-      store.storageSize = this.keys.reduce((sum, key) => sum
-        + key.length + 4 + this.values[key].length + 2, 2);
-    },
-    updateValue({
+  } else {
+    await updateValue(cur);
+  }
+}
+function onCancel() {
+  const cur = current.value;
+  if (cur.dirty) {
+    const key = `${cur.key} ${Math.random() * 1e9 | 0}`;
+    const val = cm.getValue();
+    const rawValue = dumpScriptValue(val);
+    (trash.value || (trash.value = {}))[key] = {
       key,
-      jsonValue,
-      rawValue = dumpScriptValue(jsonValue) || '',
-    }) {
-      const { id } = this.script.props;
-      return sendCmdDirectly('UpdateValue', { id, key, raw: rawValue }, undefined, this.sender)
-      .then(() => {
-        if (rawValue) {
-          this.values[key] = rawValue;
-        } else {
-          delete this.values[key];
-        }
-        this.calcSize();
-      });
-    },
-    onNew() {
-      this.current = {
-        isNew: true,
-        key: '',
-        value: '',
-        ...currentObservables,
-      };
-    },
-    async onRemove(key) {
-      this.updateValue({ key });
-      (this.trash || (this.trash = {}))[key + Math.random()] = {
-        key,
-        rawValue: this.values[key],
-        cut: this.getValue(key, true),
-        len: this.getLength(key),
-      };
-      if (this.current?.key === key) {
-        this.current = null;
-      }
-    },
-    onRestore(trashKey) {
-      const { trash } = this;
-      const { key, rawValue } = trash[trashKey];
-      delete trash[trashKey];
-      if (isEmpty(trash)) this.trash = null;
-      this.updateValue({ key, rawValue });
-    },
-    onEdit(key) {
-      this.current = {
-        key,
-        value: this.getValue(key),
-        ...currentObservables,
-      };
-    },
-    onEditAll() {
-      this.current = {
-        isAll: true,
-        value: this.getValueAll(),
-        ...currentObservables,
-      };
-    },
-    async onSave(buttonIndex) {
-      const { cm, current } = this;
-      if (current.jsonPaused) {
-        current.jsonPaused = false;
-        this.onChange();
-      }
-      if (current.error) {
-        const pos = current.errorPos;
-        cm.setSelection(pos, { line: pos.line, ch: pos.ch + 1 });
-        cm.focus();
-        showMessage({ text: current.error });
-        return;
-      }
-      if (buttonIndex === 1) {
-        cm.markClean();
-        current.dirty = false;
-      } else {
-        this.current = null;
-      }
-      if (current.isAll) {
-        await sendCmdDirectly('SetValueStores', {
-          [this.script.props.id]: current.jsonValue::mapEntry(val => dumpScriptValue(val) || ''),
-        });
-      } else {
-        await this.updateValue(current);
-      }
-    },
-    onCancel() {
-      const cur = this.current;
-      if (cur.dirty) {
-        const key = `${cur.key} ${Math.random() * 1e9 | 0}`;
-        const val = this.cm.getValue();
-        const rawValue = dumpScriptValue(val);
-        (this.trash || (this.trash = {}))[key] = {
-          key,
-          rawValue,
-          cut: cutLength(val),
-          len: this.getLength(key, rawValue),
-        };
-      }
-      this.current = null;
-    },
-    onChange(isChanged) {
-      const { current } = this;
-      current.dirty = isChanged;
-      current.error = null;
-      if (current.jsonPaused) return;
-      const { cm } = this;
-      const t0 = performance.now();
-      try {
-        const str = cm.getValue();
-        current.jsonValue = str.trim() ? JSON.parse(str) : undefined;
-      } catch (e) {
-        const re = /(position\s+)(\d+)|$/;
-        const pos = cm.posFromIndex(+`${e}`.match(re)[2] || 0);
-        current.error = `${e}`.replace(re, `$1${pos.line + 1}:${pos.ch + 1}`);
-        current.errorPos = pos;
-        current.jsonValue = undefined;
-      }
-      current.jsonPaused = performance.now() - t0 > MAX_JSON_DURATION;
-    },
-    onStorageChanged(changes) {
-      const data = Object.values(changes)[0].newValue;
-      if (data) {
-        const { current } = this;
-        const currentKey = current?.key;
-        const valueGetter = current && (current.isAll ? this.getValueAll : this.getValue);
-        this.setData(data instanceof Object ? data : deepCopy(data));
-        if (current) {
-          const newText = valueGetter(currentKey);
-          const curText = this.cm.getValue();
-          if (curText === newText) {
-            current.isNew = false;
-            current.dirty = false;
-          } else if (!current.dirty) {
-            // Updating the current value only if it wasn't yet changed by the user.
-            // Keeping the same this.current to avoid triggering `watch` observer
-            current.value = newText;
-            this.onChange();
-          }
-        }
-      } else {
-        this.setData(data);
+      rawValue,
+      cut: cutLength(val),
+      len: getLength(key, rawValue),
+    };
+  }
+  current.value = null;
+}
+function onChange(isChanged) {
+  const cur = current.value;
+  cur.dirty = isChanged;
+  cur.error = null;
+  if (cur.jsonPaused) return;
+  const t0 = performance.now();
+  try {
+    const str = cm.getValue();
+    cur.jsonValue = str.trim() ? JSON.parse(str) : undefined;
+  } catch (e) {
+    const re = /(position\s+)(\d+)|$/;
+    const pos = cm.posFromIndex(+`${e}`.match(re)[2] || 0);
+    cur.error = `${e}`.replace(re, `$1${pos.line + 1}:${pos.ch + 1}`);
+    cur.errorPos = pos;
+    cur.jsonValue = undefined;
+  }
+  cur.jsonPaused = performance.now() - t0 > MAX_JSON_DURATION;
+}
+function onStorageChanged(changes) {
+  const data = Object.values(changes)[0].newValue;
+  if (data) {
+    const cur = current.value;
+    const currentKey = cur?.key;
+    const valueGetter = cur && (cur.isAll ? getValueAll : getValue);
+    setData(data instanceof Object ? data : deepCopy(data));
+    if (cur) {
+      const newText = valueGetter(currentKey);
+      const curText = cm.getValue();
+      if (curText === newText) {
+        cur.isNew = false;
+        cur.dirty = false;
+      } else if (!cur.dirty) {
+        // Updating the current value only if it wasn't yet changed by the user.
+        // Keeping the same current.value to avoid triggering `watch` observer
+        cur.value = newText;
+        onChange();
       }
-    },
-    onUpDown(evt) {
-      handleTabNavigation(evt.key === 'ArrowDown' && 1
-        || evt.target !== this.$refs.editAll && -1
-        || 0); // Prevents Up from escaping the table since we don't listen for Down outside
-    },
-  },
-};
+    }
+  } else {
+    setData(data);
+  }
+}
+function onUpDown(evt) {
+  handleTabNavigation(evt.key === 'ArrowDown' && 1
+    || evt.target !== $editAll.value && -1
+    || 0); // Prevents Up from escaping the table since we don't listen for Down outside
+}
 </script>
 
 <style>

+ 5 - 1
src/options/views/tab-installed.vue

@@ -127,13 +127,17 @@
         />
       </div>
     </div>
-    <teleport to="body" v-if="state.script">
+    <teleport to="body">
+      <!-- KeepAlive must be a direct parent of the component, not of teleport -->
+      <KeepAlive :key="store.route.hash" :max="5">
       <edit
+        v-if="state.script"
         :initial="state.script"
         :initial-code="state.code"
         :read-only="!!state.script.config.removed"
         @close="handleEditScript()"
       />
+      </KeepAlive>
     </teleport>
   </div>
 </template>