소스 검색

Merge pull request #1243 from gera2ld/feat/keyboard

feat: add keyboard support
Gerald 4 년 전
부모
커밋
8ab3872589

+ 2 - 1
package.json

@@ -60,13 +60,14 @@
   "license": "MIT",
   "license": "MIT",
   "dependencies": {
   "dependencies": {
     "@babel/runtime": "^7.9.2",
     "@babel/runtime": "^7.9.2",
+    "@violentmonkey/shortcut": "^1.2.3",
     "@zip.js/zip.js": "^2.2.26",
     "@zip.js/zip.js": "^2.2.26",
     "codemirror": "5.61.0",
     "codemirror": "5.61.0",
     "codemirror-js-mixed": "^0.9.2",
     "codemirror-js-mixed": "^0.9.2",
     "core-js": "^3.6.4",
     "core-js": "^3.6.4",
     "tldjs": "^2.3.1",
     "tldjs": "^2.3.1",
     "vue": "^2.6.11",
     "vue": "^2.6.11",
-    "vueleton": "^1.0.4"
+    "vueleton": "^1.0.6"
   },
   },
   "engines": {
   "engines": {
     "node": ">=10"
     "node": ">=10"

+ 74 - 0
src/common/keyboard.js

@@ -0,0 +1,74 @@
+import { KeyboardService } from '@violentmonkey/shortcut';
+
+export * from '@violentmonkey/shortcut';
+
+export const keyboardService = new KeyboardService();
+
+bindKeys();
+
+export function isInput(el) {
+  return ['input', 'textarea'].includes(el?.tagName?.toLowerCase());
+}
+
+function handleFocus(e) {
+  if (isInput(e.target)) {
+    keyboardService.setContext('inputFocus', true);
+  }
+}
+
+function handleBlur(e) {
+  if (isInput(e.target)) {
+    keyboardService.setContext('inputFocus', false);
+  } else {
+    const event = new CustomEvent('tiphide', {
+      bubbles: true,
+    });
+    e.target.dispatchEvent(event);
+  }
+}
+
+function handleEscape() {
+  document.activeElement.blur();
+}
+
+export function toggleTip(el) {
+  const event = new CustomEvent('tiptoggle', {
+    bubbles: true,
+  });
+  el.dispatchEvent(event);
+}
+
+function bindKeys() {
+  document.addEventListener('focus', handleFocus, true);
+  document.addEventListener('blur', handleBlur, true);
+  keyboardService.register('escape', handleEscape);
+  keyboardService.register('c-[', handleEscape);
+  keyboardService.register('enter', () => {
+    const { activeElement } = document;
+    activeElement.click();
+  }, {
+    condition: '!inputFocus',
+  });
+  keyboardService.register('?', () => {
+    toggleTip(document.activeElement);
+  }, {
+    condition: '!inputFocus',
+    caseSensitive: true,
+  });
+}
+
+/**
+ * Note: This is only used in Firefox to work around the issue that <a> cannot be focused.
+ * Ref: https://stackoverflow.com/a/11713537/4238335
+ */
+export function handleTabNavigation(dir) {
+  const els = Array.from(document.querySelectorAll('[tabindex="0"],a[href],button,input,select,textarea'))
+  .filter(el => {
+    if (el.tabIndex < 0) return false;
+    const rect = el.getBoundingClientRect();
+    return rect.width > 0 && rect.height > 0;
+  });
+  let index = els.indexOf(document.activeElement);
+  index = (index + dir + els.length) % els.length;
+  els[index].focus();
+}

+ 37 - 9
src/common/ui/style/style.css

@@ -28,6 +28,7 @@
   --tooltip-color: white;
   --tooltip-color: white;
   --tooltip-bg: rgba(0,0,0,.8);
   --tooltip-bg: rgba(0,0,0,.8);
   --tooltip-border-color: transparent;
   --tooltip-border-color: transparent;
+  --focus-border-color: var(--fill-12);
   @media (prefers-color-scheme: dark) {
   @media (prefers-color-scheme: dark) {
     --fill-0: #222222;
     --fill-0: #222222;
     --fill-0-5: #272727;
     --fill-0-5: #272727;
@@ -84,9 +85,14 @@ h1, h2, h3 {
 }
 }
 a {
 a {
   color: dodgerblue;
   color: dodgerblue;
+  text-decoration: none;
   @media (prefers-color-scheme: dark) {
   @media (prefers-color-scheme: dark) {
     color: #7baaff;
     color: #7baaff;
   }
   }
+  &:focus,
+  &:hover {
+    text-decoration: underline;
+  }
 }
 }
 hr {
 hr {
   margin: .5rem;
   margin: .5rem;
@@ -95,6 +101,21 @@ hr {
 }
 }
 input[type=checkbox] {
 input[type=checkbox] {
   margin-right: .2em;
   margin-right: .2em;
+  &:focus + * {
+    text-decoration: underline;
+  }
+}
+button,
+input[type="text"],
+input[type="search"],
+input[type="number"],
+input[type="password"],
+select,
+textarea {
+  border: 1px solid var(--fill-3);
+  &:focus {
+    border-color: var(--focus-border-color);
+  }
 }
 }
 input[disabled] ~ * {
 input[disabled] ~ * {
   opacity: .5;
   opacity: .5;
@@ -102,6 +123,7 @@ input[disabled] ~ * {
 input[type=text],
 input[type=text],
 input[type=url],
 input[type=url],
 input[type=search],
 input[type=search],
+input[type=number],
 input[type=password] {
 input[type=password] {
   line-height: 1.5rem;
   line-height: 1.5rem;
   &[disabled] {
   &[disabled] {
@@ -116,13 +138,10 @@ textarea {
 input[type=text],
 input[type=text],
 input[type=url],
 input[type=url],
 input[type=search],
 input[type=search],
+input[type=number],
 input[type=password],
 input[type=password],
 textarea {
 textarea {
   padding: 0 .5rem;
   padding: 0 .5rem;
-  border: 1px solid var(--fill-3);
-  &:focus {
-    border-color: var(--fill-7);
-  }
 }
 }
 code {
 code {
   padding: 0 .2em;
   padding: 0 .2em;
@@ -165,9 +184,6 @@ button {
   &:active {
   &:active {
     background: var(--fill-5);
     background: var(--fill-5);
   }
   }
-  &:focus {
-    border-color: var(--fg);
-  }
   &[disabled] {
   &[disabled] {
     opacity: .5;
     opacity: .5;
   }
   }
@@ -189,6 +205,7 @@ button,
   color: inherit;
   color: inherit;
   border: 1px solid transparent;
   border: 1px solid transparent;
   cursor: pointer;
   cursor: pointer;
+  &:focus,
   &:hover {
   &:hover {
     border-color: var(--fill-5);
     border-color: var(--fill-5);
     background: var(--bg);
     background: var(--bg);
@@ -203,6 +220,11 @@ button,
   }
   }
 }
 }
 
 
+span:focus,
+a:focus {
+  text-decoration: underline;
+}
+
 .sep {
 .sep {
   &::after {
   &::after {
     content: '';
     content: '';
@@ -375,7 +397,7 @@ body .vl-tooltip {
     height: 1em;
     height: 1em;
     background: var(--input-bg);
     background: var(--input-bg);
     position: relative;
     position: relative;
-    border: 1px solid #555;
+    border: 1px solid var(--fill-3);
     &:checked::after {
     &:checked::after {
       content: "";
       content: "";
       background: var(--fg);
       background: var(--fg);
@@ -386,6 +408,9 @@ body .vl-tooltip {
       bottom: 2px;
       bottom: 2px;
       position: absolute;
       position: absolute;
     }
     }
+    &:focus {
+      border-color: var(--focus-border-color);
+    }
   }
   }
   input[type="radio"],
   input[type="radio"],
   input[type="radio"]:checked::after {
   input[type="radio"]:checked::after {
@@ -400,7 +425,10 @@ body .vl-tooltip {
   textarea {
   textarea {
     background: var(--input-bg);
     background: var(--input-bg);
     color: var(--fg);
     color: var(--fg);
-    border: 1px solid var(--fill-4);
+    border: 1px solid var(--fill-3);
+    &:focus {
+      border-color: var(--focus-border-color);
+    }
   }
   }
   ::-webkit-scrollbar {
   ::-webkit-scrollbar {
     width: 14px;
     width: 14px;

+ 0 - 1
src/options/style.css

@@ -35,7 +35,6 @@ aside {
     padding-bottom: .6rem;
     padding-bottom: .6rem;
     font-size: 1rem;
     font-size: 1rem;
     font-weight: 500;
     font-weight: 500;
-    text-decoration: none;
     color: var(--fill-8);
     color: var(--fill-8);
     &.active,
     &.active,
     &:hover {
     &:hover {

+ 0 - 60
src/options/utils/hotkeys.js

@@ -1,60 +0,0 @@
-import { route } from '#/common/router';
-
-routeChanged();
-
-export function routeChanged() {
-  const enable = !route.pathname || route.pathname === 'scripts';
-  document[`${enable ? 'add' : 'remove'}EventListener`]('keydown', onKeyDown);
-}
-
-function onKeyDown(e) {
-  if (e.altKey || e.shiftKey || e.metaKey) {
-    return;
-  }
-  const filterEl = document.querySelector('.filter-search input');
-  const activeEl = document.activeElement;
-  if (activeEl !== filterEl && activeEl?.matches?.('button, input, select, textarea')) {
-    return;
-  }
-  if (e.key.length === 1 && !e.ctrlKey || e.code === 'KeyF' && e.ctrlKey) {
-    filterEl.focus();
-    if (e.ctrlKey) e.preventDefault();
-    return;
-  }
-  if (e.ctrlKey) {
-    return;
-  }
-  let el = document.querySelector('.script.focused');
-  switch (e.key) {
-  case 'Enter':
-    if (el) {
-      e.preventDefault();
-      el.dispatchEvent(new Event('keydownEnter'));
-    }
-    break;
-  case 'ArrowUp':
-  case 'ArrowDown': {
-    e.preventDefault();
-    const dir = e.key === 'ArrowUp' ? -1 : 1;
-    const all = document.querySelectorAll('.script:not([style*="display"])');
-    const numScripts = all.length;
-    if (!numScripts) {
-      return;
-    }
-    if (!el) {
-      all[dir > 0 ? 0 : numScripts - 1].classList.add('focused');
-      return;
-    }
-    el.classList.remove('focused');
-    el = all[([...all].indexOf(el) + dir + numScripts) % numScripts];
-    el.classList.add('focused');
-    const bounds = el.getBoundingClientRect();
-    const parentBounds = el.parentElement.getBoundingClientRect();
-    if (bounds.top > parentBounds.bottom || bounds.bottom < parentBounds.top) {
-      el.scrollIntoView({ behavior: 'smooth' });
-    }
-    break;
-  }
-  default:
-  }
-}

+ 51 - 27
src/options/views/app.vue

@@ -6,19 +6,11 @@
         <h1 class="hidden-xs" v-text="i18n('extName')"></h1>
         <h1 class="hidden-xs" v-text="i18n('extName')"></h1>
         <div class="aside-menu">
         <div class="aside-menu">
           <a
           <a
-            href="#scripts"
-            :class="{active: tab === 'scripts'}"
-            v-text="i18n('sideMenuInstalled')"
-          />
-          <a
-            href="#settings"
-            :class="{active: tab === 'settings'}"
-            v-text="i18n('sideMenuSettings')"
-          />
-          <a
-            href="#about"
-            :class="{active: tab === 'about'}"
-            v-text="i18n('sideMenuAbout')"
+            v-for="tab in tabs"
+            :key="tab.name"
+            :href="`#${tab.name}`"
+            :class="{active: tab === current}"
+            v-text="tab.label"
           />
           />
         </div>
         </div>
       </div>
       </div>
@@ -32,41 +24,42 @@
 <script>
 <script>
 import { i18n } from '#/common';
 import { i18n } from '#/common';
 import Icon from '#/common/ui/icon';
 import Icon from '#/common/ui/icon';
+import { keyboardService } from '#/common/keyboard';
 import { store } from '../utils';
 import { store } from '../utils';
-import * as Hotkeys from '../utils/hotkeys';
 import Installed from './tab-installed';
 import Installed from './tab-installed';
 import Settings from './tab-settings';
 import Settings from './tab-settings';
 import About from './tab-about';
 import About from './tab-about';
 
 
-const tabs = {
-  scripts: Installed,
-  settings: Settings,
-  about: About,
-};
+const tabs = [
+  { name: 'scripts', comp: Installed, label: i18n('sideMenuInstalled') },
+  { name: 'settings', comp: Settings, label: i18n('sideMenuSettings') },
+  { name: 'about', comp: About, label: i18n('sideMenuAbout') },
+];
 const extName = i18n('extName');
 const extName = i18n('extName');
+const conditionNotEdit = '!editScript';
 
 
 export default {
 export default {
   components: {
   components: {
     Icon,
     Icon,
   },
   },
   data() {
   data() {
-    const [tab, tabFunc] = store.route.paths;
+    const [name, tabFunc] = store.route.paths;
     return {
     return {
+      tabs,
       aside: false,
       aside: false,
       // Speedup and deflicker for initial page load:
       // Speedup and deflicker for initial page load:
       // skip rendering the aside when starting in the editor for a new script.
       // skip rendering the aside when starting in the editor for a new script.
-      canRenderAside: tab !== 'scripts' || (tabFunc !== '_new' && !Number(tabFunc)),
+      canRenderAside: name !== 'scripts' || (tabFunc !== '_new' && !Number(tabFunc)),
       store,
       store,
     };
     };
   },
   },
   computed: {
   computed: {
-    tab() {
-      let tab = this.store.route.paths[0];
-      if (!tabs[tab]) tab = 'scripts';
-      return tab;
+    current() {
+      const name = this.store.route.paths[0];
+      return tabs.find(tab => tab.name === name) || tabs[0];
     },
     },
     tabComponent() {
     tabComponent() {
-      return tabs[this.tab];
+      return this.current.comp;
     },
     },
   },
   },
   watch: {
   watch: {
@@ -74,11 +67,42 @@ export default {
       document.title = title ? `${title} - ${extName}` : extName;
       document.title = title ? `${title} - ${extName}` : extName;
     },
     },
     'store.route.paths'() {
     'store.route.paths'() {
-      Hotkeys.routeChanged();
       // First time showing the aside we need to tell v-if to keep it forever
       // First time showing the aside we need to tell v-if to keep it forever
       this.canRenderAside = true;
       this.canRenderAside = true;
+      this.updateContext();
     },
     },
   },
   },
+  methods: {
+    updateContext() {
+      const isScriptsTab = this.current.name === 'scripts';
+      const { paths } = this.store.route;
+      keyboardService.setContext('editScript', isScriptsTab && paths[1]);
+      keyboardService.setContext('tabScripts', isScriptsTab && !paths[1]);
+    },
+    switchTab(step) {
+      const index = this.tabs.indexOf(this.current);
+      const switchTo = this.tabs[(index + step + this.tabs.length) % this.tabs.length];
+      window.location.hash = switchTo?.name || '';
+    },
+  },
+  mounted() {
+    this.disposeList = [
+      keyboardService.register('a-pageup', () => this.switchTab(-1), {
+        condition: conditionNotEdit,
+      }),
+      keyboardService.register('a-pagedown', () => this.switchTab(1), {
+        condition: conditionNotEdit,
+      }),
+    ];
+    keyboardService.enable();
+    this.updateContext();
+  },
+  beforeDestroy() {
+    this.disposeList?.forEach(dispose => {
+      dispose();
+    });
+    keyboardService.disable();
+  },
 };
 };
 </script>
 </script>
 
 

+ 27 - 22
src/options/views/edit/index.vue

@@ -60,10 +60,10 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import CodeMirror from 'codemirror';
 import { debounce, getScriptName, i18n, isEmpty, sendCmd, trueJoin } from '#/common';
 import { debounce, getScriptName, i18n, isEmpty, sendCmd, trueJoin } from '#/common';
 import { deepCopy, deepEqual, objectPick } from '#/common/object';
 import { deepCopy, deepEqual, objectPick } from '#/common/object';
 import { showMessage } from '#/common/ui';
 import { showMessage } from '#/common/ui';
+import { keyboardService } from '#/common/keyboard';
 import VmCode from '#/common/ui/code';
 import VmCode from '#/common/ui/code';
 import options from '#/common/options';
 import options from '#/common/options';
 import { route, getUnloadSentry } from '#/common/router';
 import { route, getUnloadSentry } from '#/common/router';
@@ -179,8 +179,12 @@ export default {
     },
     },
   },
   },
   watch: {
   watch: {
+    nav(val) {
+      keyboardService.setContext('tabCode', val === 'code');
+    },
     canSave(val) {
     canSave(val) {
       this.toggleUnloadSentry(val);
       this.toggleUnloadSentry(val);
+      keyboardService.setContext('canSave', val);
     },
     },
     // usually errors for resources
     // usually errors for resources
     'initial.error'(error) {
     'initial.error'(error) {
@@ -194,7 +198,6 @@ export default {
     this.toggleUnloadSentry = getUnloadSentry(null, () => {
     this.toggleUnloadSentry = getUnloadSentry(null, () => {
       this.$refs.code.cm.focus();
       this.$refs.code.cm.focus();
     });
     });
-    document.addEventListener('keydown', this.switchPanel);
     if (options.get('editorWindow') && global.history.length === 1) {
     if (options.get('editorWindow') && global.history.length === 1) {
       browser.windows?.getCurrent({ populate: true }).then(setupSavePosition);
       browser.windows?.getCurrent({ populate: true }).then(setupSavePosition);
     }
     }
@@ -243,6 +246,16 @@ export default {
       }
       }
       this.hotkeys = hotkeys;
       this.hotkeys = hotkeys;
     }
     }
+    this.disposeList = [
+      keyboardService.register('a-pageup', this.switchPrevPanel),
+      keyboardService.register('a-pagedown', this.switchNextPanel),
+      keyboardService.register('ctrlcmd-s', this.save, {
+        condition: 'canSave',
+      }),
+      keyboardService.register('escape', () => { this.nav = 'code'; }, {
+        condition: '!tabCode',
+      }),
+    ];
   },
   },
   methods: {
   methods: {
     async save() {
     async save() {
@@ -289,25 +302,15 @@ export default {
     saveClose() {
     saveClose() {
       this.save().then(this.close);
       this.save().then(this.close);
     },
     },
-    switchPanel(e) {
-      const key = CodeMirror.keyName(e);
-      switch (key) {
-      case K_PREV_PANEL:
-      case K_NEXT_PANEL: {
-        const dir = key === K_NEXT_PANEL ? 1 : -1;
-        const keys = Object.keys(this.navItems);
-        this.nav = keys[(keys.indexOf(this.nav) + dir + keys.length) % keys.length];
-        break;
-      }
-      case K_SAVE:
-        if (this.canSave) this.save();
-        e.preventDefault();
-        break;
-      case 'Esc':
-        this.nav = 'code';
-        break;
-      default:
-      }
+    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() {
     onChange() {
       this.canSave = this.codeDirty || !deepEqual(this.settings, savedSettings);
       this.canSave = this.codeDirty || !deepEqual(this.settings, savedSettings);
@@ -316,7 +319,9 @@ export default {
   beforeDestroy() {
   beforeDestroy() {
     store.title = null;
     store.title = null;
     this.toggleUnloadSentry(false);
     this.toggleUnloadSentry(false);
-    document.removeEventListener('keydown', this.switchPanel);
+    this.disposeList?.forEach(dispose => {
+      dispose();
+    });
   },
   },
 };
 };
 </script>
 </script>

+ 145 - 52
src/options/views/script-item.vue

@@ -5,13 +5,25 @@
       disabled: !script.config.enabled,
       disabled: !script.config.enabled,
       removed: script.config.removed,
       removed: script.config.removed,
       error: script.error,
       error: script.error,
+      focused: focused,
+      hotkeys: focused && showHotkeys,
     }"
     }"
+    :tabIndex="tabIndex"
     :draggable="draggable"
     :draggable="draggable"
-    @keydownEnter="onEdit">
-    <img class="script-icon hidden-xs" :src="script.safeIcon" @click="onEdit">
+    @focus="onFocus"
+    @blur="onBlur">
+    <div class="script-icon hidden-xs">
+      <a @click="onEdit" :data-hotkey="hotkeys.edit" data-hotkey-table tabIndex="-1">
+        <img :src="script.safeIcon">
+      </a>
+    </div>
     <div class="script-info flex ml-1c">
     <div class="script-info flex ml-1c">
-      <div class="script-name ellipsis flex-auto" v-text="script.$cache.name"
-           @click.exact="nameClickable && onEdit()"/>
+      <span
+        class="script-name ellipsis flex-auto"
+        v-text="script.$cache.name"
+        @click.exact="nameClickable && onEdit()"
+        :tabIndex="nameClickable ? tabIndex : -1"
+      />
       <template v-if="canRender">
       <template v-if="canRender">
         <tooltip v-if="author" :content="i18n('labelAuthor') + script.meta.author"
         <tooltip v-if="author" :content="i18n('labelAuthor') + script.meta.author"
                  class="script-author ml-1c hidden-sm"
                  class="script-author ml-1c hidden-sm"
@@ -22,6 +34,7 @@
             class="ellipsis"
             class="ellipsis"
             :href="`mailto:${author.email}`"
             :href="`mailto:${author.email}`"
             v-text="author.name"
             v-text="author.name"
+            :tabIndex="tabIndex"
           />
           />
           <span class="ellipsis" v-else v-text="author.name" />
           <span class="ellipsis" v-else v-text="author.name" />
         </tooltip>
         </tooltip>
@@ -31,9 +44,13 @@
         </tooltip>
         </tooltip>
         <div v-if="script.config.removed">
         <div v-if="script.config.removed">
           <tooltip :content="i18n('buttonRestore')" placement="left">
           <tooltip :content="i18n('buttonRestore')" placement="left">
-            <span class="btn-ghost" @click="onRestore">
+            <a
+              class="btn-ghost"
+              @click="onRestore"
+              :data-hotkey="hotkeys.restore"
+              :tabIndex="tabIndex">
               <icon name="undo"></icon>
               <icon name="undo"></icon>
-            </span>
+            </a>
           </tooltip>
           </tooltip>
         </div>
         </div>
       </template>
       </template>
@@ -42,33 +59,46 @@
       <template v-if="canRender">
       <template v-if="canRender">
         <div class="flex-auto flex flex-wrap">
         <div class="flex-auto flex flex-wrap">
           <tooltip :content="i18n('buttonEdit')" align="start">
           <tooltip :content="i18n('buttonEdit')" align="start">
-            <span class="btn-ghost" @click="onEdit">
+            <a class="btn-ghost" @click="onEdit" :data-hotkey="hotkeys.edit" :tabIndex="tabIndex">
               <icon name="code"></icon>
               <icon name="code"></icon>
-            </span>
+            </a>
           </tooltip>
           </tooltip>
           <tooltip :content="labelEnable" align="start">
           <tooltip :content="labelEnable" align="start">
-            <span class="btn-ghost" @click="onEnable">
+            <a
+              class="btn-ghost"
+              @click="onToggle"
+              :data-hotkey="hotkeys.toggle"
+              :tabIndex="tabIndex">
               <icon :name="`toggle-${script.config.enabled ? 'on' : 'off'}`"></icon>
               <icon :name="`toggle-${script.config.enabled ? 'on' : 'off'}`"></icon>
-            </span>
+            </a>
           </tooltip>
           </tooltip>
           <tooltip
           <tooltip
             :disabled="!canUpdate || script.checking"
             :disabled="!canUpdate || script.checking"
             :content="i18n('buttonUpdate')"
             :content="i18n('buttonUpdate')"
             align="start">
             align="start">
-            <span class="btn-ghost" @click="onUpdate">
+            <a
+              class="btn-ghost"
+              @click="onUpdate"
+              :data-hotkey="hotkeys.update"
+              :tabIndex="canUpdate ? tabIndex : -1">
               <icon name="refresh"></icon>
               <icon name="refresh"></icon>
-            </span>
+            </a>
           </tooltip>
           </tooltip>
           <span class="sep"></span>
           <span class="sep"></span>
           <tooltip :disabled="!homepageURL" :content="i18n('buttonHome')" align="start">
           <tooltip :disabled="!homepageURL" :content="i18n('buttonHome')" align="start">
-            <a class="btn-ghost" target="_blank" rel="noopener noreferrer" :href="homepageURL">
+            <a
+              class="btn-ghost"
+              target="_blank"
+              rel="noopener noreferrer"
+              :href="homepageURL"
+              :tabIndex="homepageURL ? tabIndex : -1">
               <icon name="home"></icon>
               <icon name="home"></icon>
             </a>
             </a>
           </tooltip>
           </tooltip>
           <tooltip :disabled="!description" :content="description" align="start">
           <tooltip :disabled="!description" :content="description" align="start">
-            <span class="btn-ghost">
+            <a class="btn-ghost" :tabIndex="description ? tabIndex : -1" @click="toggleTip">
               <icon name="info"></icon>
               <icon name="info"></icon>
-            </span>
+            </a>
           </tooltip>
           </tooltip>
           <tooltip
           <tooltip
             :disabled="!script.meta.supportURL"
             :disabled="!script.meta.supportURL"
@@ -78,6 +108,7 @@
               class="btn-ghost"
               class="btn-ghost"
               target="_blank"
               target="_blank"
               rel="noopener noreferrer"
               rel="noopener noreferrer"
+              :tabIndex="script.meta.supportURL ? tabIndex : -1"
               :href="script.meta.supportURL">
               :href="script.meta.supportURL">
               <icon name="question"></icon>
               <icon name="question"></icon>
             </a>
             </a>
@@ -85,9 +116,9 @@
           <div class="script-message" v-text="script.message" :title="script.error"></div>
           <div class="script-message" v-text="script.message" :title="script.error"></div>
         </div>
         </div>
         <tooltip :content="i18n('buttonRemove')" align="end">
         <tooltip :content="i18n('buttonRemove')" align="end">
-          <span class="btn-ghost" @click="onRemove">
+          <a class="btn-ghost" @click="onRemove" :data-hotkey="hotkeys.remove" :tabIndex="tabIndex">
             <icon name="trash"></icon>
             <icon name="trash"></icon>
-          </span>
+          </a>
         </tooltip>
         </tooltip>
       </template>
       </template>
     </div>
     </div>
@@ -96,16 +127,22 @@
 
 
 <script>
 <script>
 import Tooltip from 'vueleton/lib/tooltip/bundle';
 import Tooltip from 'vueleton/lib/tooltip/bundle';
-import { sendCmd, getLocaleString, formatTime } from '#/common';
+import { getLocaleString, formatTime } from '#/common';
 import Icon from '#/common/ui/icon';
 import Icon from '#/common/ui/icon';
+import { keyboardService, isInput, toggleTip } from '#/common/keyboard';
 import enableDragging from '../utils/dragging';
 import enableDragging from '../utils/dragging';
 
 
+const itemMargin = 8;
+
 export default {
 export default {
   props: [
   props: [
     'script',
     'script',
     'draggable',
     'draggable',
     'visible',
     'visible',
     'nameClickable',
     'nameClickable',
+    'focused',
+    'hotkeys',
+    'showHotkeys',
   ],
   ],
   components: {
   components: {
     Icon,
     Icon,
@@ -167,12 +204,33 @@ export default {
       }
       }
       return ret;
       return ret;
     },
     },
+    tabIndex() {
+      return this.focused ? 0 : -1;
+    },
   },
   },
   watch: {
   watch: {
     visible(visible) {
     visible(visible) {
       // Leave it if the element is already rendered
       // Leave it if the element is already rendered
       if (visible) this.canRender = true;
       if (visible) this.canRender = true;
     },
     },
+    focused(value, prevValue) {
+      const { $el } = this;
+      if (value && !prevValue && $el) {
+        const rect = $el.getBoundingClientRect();
+        const pRect = $el.parentNode.getBoundingClientRect();
+        let delta = 0;
+        if (rect.bottom > pRect.bottom - itemMargin) {
+          delta += rect.bottom - pRect.bottom + itemMargin;
+        } else if (rect.top < pRect.top + itemMargin) {
+          delta -= pRect.top - rect.top + itemMargin;
+        }
+        if (!isInput(document.activeElement)) {
+          // focus without scrolling, then scroll smoothly
+          $el.focus({ preventScroll: true });
+        }
+        this.$emit('scrollDelta', delta);
+      }
+    },
   },
   },
   mounted() {
   mounted() {
     enableDragging(this.$el, {
     enableDragging(this.$el, {
@@ -181,32 +239,28 @@ export default {
   },
   },
   methods: {
   methods: {
     onEdit() {
     onEdit() {
-      this.$emit('edit', this.script.props.id);
-    },
-    markRemoved(removed) {
-      sendCmd('MarkRemoved', {
-        id: this.script.props.id,
-        removed,
-      });
+      this.$emit('edit', this.script);
     },
     },
     onRemove() {
     onRemove() {
-      const rect = this.$el.getBoundingClientRect();
-      this.markRemoved(1);
-      this.$emit('remove', this.script.props.id, rect);
+      this.$emit('remove', this.script);
     },
     },
     onRestore() {
     onRestore() {
-      this.markRemoved(0);
+      this.$emit('restore', this.script);
     },
     },
-    onEnable() {
-      sendCmd('UpdateScriptInfo', {
-        id: this.script.props.id,
-        config: {
-          enabled: this.script.config.enabled ? 0 : 1,
-        },
-      });
+    onToggle() {
+      this.$emit('toggle', this.script);
     },
     },
     onUpdate() {
     onUpdate() {
-      sendCmd('CheckUpdate', this.script.props.id);
+      this.$emit('update', this.script);
+    },
+    onFocus() {
+      keyboardService.setContext('scriptFocus', true);
+    },
+    onBlur() {
+      keyboardService.setContext('scriptFocus', false);
+    },
+    toggleTip(e) {
+      toggleTip(e.target);
     },
     },
   },
   },
 };
 };
@@ -241,7 +295,7 @@ $removedItemHeight: calc(
 
 
 .script {
 .script {
   position: relative;
   position: relative;
-  margin: $itemMargin;
+  margin: $itemMargin 0 0 $itemMargin;
   padding: $itemPadT 10px $itemPadB;
   padding: $itemPadT 10px $itemPadB;
   border: 1px solid var(--fill-3);
   border: 1px solid var(--fill-3);
   border-radius: .3rem;
   border-radius: .3rem;
@@ -284,7 +338,13 @@ $removedItemHeight: calc(
     }
     }
   }
   }
   &.focused {
   &.focused {
-    box-shadow: 1px 2px 9px var(--fill-8);
+    // bring the focused item to the front so that the box-shadow will not be overlapped
+    // by the next item
+    z-index: 1;
+    box-shadow: 1px 2px 9px var(--fill-7);
+    &:focus {
+      box-shadow: 1px 2px 9px var(--fill-9);
+    }
   }
   }
   &.error {
   &.error {
     border-color: #f008;
     border-color: #f008;
@@ -309,6 +369,9 @@ $removedItemHeight: calc(
     }
     }
     .disabled {
     .disabled {
       color: var(--fill-2);
       color: var(--fill-2);
+      [data-hotkey]::after {
+        content: none;
+      }
     }
     }
     .icon {
     .icon {
       display: block;
       display: block;
@@ -326,8 +389,16 @@ $removedItemHeight: calc(
     bottom: 0;
     bottom: 0;
     margin: auto;
     margin: auto;
     cursor: pointer;
     cursor: pointer;
-    &:not([src]) {
-      visibility: hidden; // hiding the empty outline border while the image loads
+    a {
+      display: block;
+    }
+    img {
+      display: block;
+      width: 100%;
+      height: 100%;
+      &:not([src]) {
+        visibility: hidden; // hiding the empty outline border while the image loads
+      }
     }
     }
     .disabled &,
     .disabled &,
     .removed & {
     .removed & {
@@ -362,16 +433,33 @@ $removedItemHeight: calc(
   }
   }
 }
 }
 
 
+.hotkeys [data-hotkey] {
+  position: relative;
+  &::after {
+    content: attr(data-hotkey);
+    position: absolute;
+    left: 50%;
+    bottom: 80%;
+    transform-origin: bottom;
+    transform: translate(-50%,0);
+    padding: .2em;
+    background: #fe6;
+    color: #333;
+    border: 1px solid #880;
+    border-radius: .2em;
+    font-size: .8rem;
+    line-height: 1;
+  }
+}
+
 .scripts {
 .scripts {
-  &:not([data-columns="1"]) {
-    display: flex;
-    flex-wrap: wrap;
-    align-content: flex-start;
-    padding: 0 $itemMargin;
-    .script {
-      // --num-columns is set in tab-installed.vue
-      width: calc(100% / var(--num-columns) - (2 * $itemMargin));
-    }
+  display: flex;
+  flex-wrap: wrap;
+  align-content: flex-start;
+  padding: 0 $itemMargin $itemMargin 0;
+  .script {
+    // --num-columns is set in tab-installed.vue
+    width: calc(100% / var(--num-columns) - $itemMargin);
   }
   }
   &[data-table] {
   &[data-table] {
     &[data-columns="1"] .script {
     &[data-columns="1"] .script {
@@ -402,7 +490,7 @@ $removedItemHeight: calc(
       display: flex;
       display: flex;
       align-items: center;
       align-items: center;
       height: 2.5rem;
       height: 2.5rem;
-      margin: 0 $itemMargin;
+      margin: 0 0 0 $itemMargin;
       padding: 0 calc(2 * $itemMargin) 0 $itemMargin;
       padding: 0 calc(2 * $itemMargin) 0 $itemMargin;
       border-top: none;
       border-top: none;
       border-radius: 0;
       border-radius: 0;
@@ -414,7 +502,7 @@ $removedItemHeight: calc(
         width: 2rem;
         width: 2rem;
         height: 2rem;
         height: 2rem;
         order: 1;
         order: 1;
-        position: static;
+        position: relative;
         margin-left: .5rem;
         margin-left: .5rem;
       }
       }
       &-info {
       &-info {
@@ -459,6 +547,11 @@ $removedItemHeight: calc(
       }
       }
     }
     }
   }
   }
+  &:not([data-table]) {
+    [data-hotkey-table]::after {
+      content: none;
+    }
+  }
 }
 }
 @media (max-width: 319px) {
 @media (max-width: 319px) {
   .script-icon ~ * {
   .script-icon ~ * {

+ 239 - 59
src/options/views/tab-installed.vue

@@ -8,43 +8,49 @@
             :class="{active: menuNewActive}"
             :class="{active: menuNewActive}"
             @stateChange="onStateChange">
             @stateChange="onStateChange">
             <tooltip :content="i18n('buttonNew')" placement="bottom" align="start" slot="toggle">
             <tooltip :content="i18n('buttonNew')" placement="bottom" align="start" slot="toggle">
-              <span class="btn-ghost">
+              <a class="btn-ghost" tabindex="0">
                 <icon name="plus"></icon>
                 <icon name="plus"></icon>
-              </span>
+              </a>
             </tooltip>
             </tooltip>
-            <div
+            <a
               class="dropdown-menu-item"
               class="dropdown-menu-item"
               v-text="i18n('buttonNew')"
               v-text="i18n('buttonNew')"
-              @click.prevent="onEditScript('_new')"
+              tabindex="0"
+              @click.prevent="editScript('_new')"
             />
             />
             <a class="dropdown-menu-item" v-text="i18n('installFrom', 'OpenUserJS')" href="https://openuserjs.org/" target="_blank" rel="noopener noreferrer"></a>
             <a class="dropdown-menu-item" v-text="i18n('installFrom', 'OpenUserJS')" href="https://openuserjs.org/" target="_blank" rel="noopener noreferrer"></a>
             <a class="dropdown-menu-item" v-text="i18n('installFrom', 'GreasyFork')" href="https://greasyfork.org/scripts" target="_blank" rel="noopener noreferrer"></a>
             <a class="dropdown-menu-item" v-text="i18n('installFrom', 'GreasyFork')" href="https://greasyfork.org/scripts" target="_blank" rel="noopener noreferrer"></a>
-            <div
+            <a
               class="dropdown-menu-item"
               class="dropdown-menu-item"
               v-text="i18n('buttonInstallFromURL')"
               v-text="i18n('buttonInstallFromURL')"
+              tabindex="0"
               @click.prevent="installFromURL"
               @click.prevent="installFromURL"
             />
             />
           </dropdown>
           </dropdown>
           <tooltip :content="i18n('buttonUpdateAll')" placement="bottom" align="start">
           <tooltip :content="i18n('buttonUpdateAll')" placement="bottom" align="start">
-            <span class="btn-ghost" @click="updateAll">
+            <a class="btn-ghost" tabindex="0" @click="updateAll">
               <icon name="refresh"></icon>
               <icon name="refresh"></icon>
-            </span>
+            </a>
           </tooltip>
           </tooltip>
         </div>
         </div>
         <div class="flex-auto" v-else
         <div class="flex-auto" v-else
              v-text="`${i18n('headerRecycleBin')}${trash.length ? ` (${trash.length})` : ''}`" />
              v-text="`${i18n('headerRecycleBin')}${trash.length ? ` (${trash.length})` : ''}`" />
         <tooltip :content="i18n('buttonRecycleBin')" placement="bottom">
         <tooltip :content="i18n('buttonRecycleBin')" placement="bottom">
-          <span class="btn-ghost trash-button" @click="toggleRecycle" ref="trash"
-                :class="{ active: showRecycle, filled: trash.length }">
-            <icon name="trash"></icon>
+          <a
+            class="btn-ghost trash-button"
+            :class="{ active: showRecycle, filled: trash.length }"
+            @click="showRecycle = !showRecycle"
+            tabindex="0"
+          >
+            <icon name="trash" :class="{ 'trash-animate': removing }"></icon>
             <b v-if="trash.length" v-text="trash.length"/>
             <b v-if="trash.length" v-text="trash.length"/>
-          </span>
+          </a>
         </tooltip>
         </tooltip>
         <dropdown align="right" class="filter-sort">
         <dropdown align="right" class="filter-sort">
           <tooltip :content="i18n('labelSettings')" placement="bottom" slot="toggle">
           <tooltip :content="i18n('labelSettings')" placement="bottom" slot="toggle">
-            <span class="btn-ghost">
+            <a class="btn-ghost" tabindex="0">
               <icon name="cog"/>
               <icon name="cog"/>
-            </span>
+            </a>
           </tooltip>
           </tooltip>
           <div>
           <div>
             <locale-group i18n-key="labelFilterSort">
             <locale-group i18n-key="labelFilterSort">
@@ -76,6 +82,7 @@
                 :class="{'has-error': searchError}"
                 :class="{'has-error': searchError}"
                 :placeholder="i18n('labelSearchScript')"
                 :placeholder="i18n('labelSearchScript')"
                 v-model="search"
                 v-model="search"
+                ref="search"
                 id="installed-search">
                 id="installed-search">
               <icon name="search"></icon>
               <icon name="search"></icon>
             </label>
             </label>
@@ -99,6 +106,7 @@
       </div>
       </div>
       <div class="flex-auto pos-rel">
       <div class="flex-auto pos-rel">
         <div class="scripts abs-full"
         <div class="scripts abs-full"
+             ref="scriptList"
              :style="`--num-columns:${numColumns}`"
              :style="`--num-columns:${numColumns}`"
              :data-columns="numColumns"
              :data-columns="numColumns"
              :data-table="filters.viewTable">
              :data-table="filters.viewTable">
@@ -106,14 +114,21 @@
             v-for="(script, index) in sortedScripts"
             v-for="(script, index) in sortedScripts"
             v-show="!search || script.$cache.show !== false"
             v-show="!search || script.$cache.show !== false"
             :key="script.props.id"
             :key="script.props.id"
-            :class="{ removing: removing && removing.id === script.props.id }"
+            :focused="selectedScript === script"
+            :showHotkeys="showHotkeys"
             :script="script"
             :script="script"
             :draggable="filters.sort.value === 'exec' && !script.config.removed"
             :draggable="filters.sort.value === 'exec' && !script.config.removed"
             :visible="index < batchRender.limit"
             :visible="index < batchRender.limit"
-            :name-clickable="filters.viewTable"
-            @edit="onEditScript"
+            :nameClickable="filters.viewTable"
+            :hotkeys="scriptHotkeys"
+            @edit="handleActionEdit"
+            @remove="handleActionRemove"
+            @restore="handleActionRestore"
+            @toggle="handleActionToggle"
+            @update="handleActionUpdate"
             @move="moveScript"
             @move="moveScript"
-            @remove="onRemove"
+            @scrollDelta="handleSmoothScroll"
+            @tiptoggle.native="showHotkeys = !showHotkeys"
           />
           />
         </div>
         </div>
         <div
         <div
@@ -124,8 +139,7 @@
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>
-    <edit v-if="script" :initial="script" @close="onEditScript()"></edit>
-    <div class="trash-animate" v-if="removing" :style="removing.animation" />
+    <edit v-if="script" :initial="script" @close="editScript()"></edit>
   </div>
   </div>
 </template>
 </template>
 
 
@@ -144,6 +158,8 @@ import LocaleGroup from '#/common/ui/locale-group';
 import { forEachKey } from '#/common/object';
 import { forEachKey } from '#/common/object';
 import { setRoute, lastRoute } from '#/common/router';
 import { setRoute, lastRoute } from '#/common/router';
 import storage from '#/common/storage';
 import storage from '#/common/storage';
+import { keyboardService, handleTabNavigation } from '#/common/keyboard';
+import ua from '#/common/ua';
 import { loadData } from '#/options';
 import { loadData } from '#/options';
 import ScriptItem from './script-item';
 import ScriptItem from './script-item';
 import Edit from './edit';
 import Edit from './edit';
@@ -209,6 +225,23 @@ let step = 0;
 let columnsForTableMode = [];
 let columnsForTableMode = [];
 let columnsForCardsMode = [];
 let columnsForCardsMode = [];
 
 
+const conditionAll = 'tabScripts';
+const conditionSearch = `${conditionAll} && inputFocus`;
+const conditionNotSearch = `${conditionAll} && !inputFocus`;
+const conditionScriptFocused = `${conditionNotSearch} && selectedScript && !showRecycle`;
+const conditionScriptFocusedRecycle = `${conditionNotSearch} && selectedScript && showRecycle`;
+const conditionHotkeys = `${conditionNotSearch} && selectedScript && showHotkeys`;
+const scriptHotkeys = {
+  edit: 'e',
+  toggle: 'space',
+  update: 'r',
+  restore: 'r',
+  remove: 'x',
+};
+const registerHotkey = (callback, items) => items.map(([key, condition, caseSensitive]) => (
+  keyboardService.register(key, callback, { condition, caseSensitive })
+));
+
 export default {
 export default {
   components: {
   components: {
     ScriptItem,
     ScriptItem,
@@ -221,9 +254,12 @@ export default {
   },
   },
   data() {
   data() {
     return {
     return {
+      scriptHotkeys,
       store,
       store,
       filterOptions,
       filterOptions,
       filters,
       filters,
+      filteredScripts: [],
+      focusedIndex: -1,
       script: null,
       script: null,
       search: null,
       search: null,
       searchError: null,
       searchError: null,
@@ -231,7 +267,8 @@ export default {
       menuNewActive: false,
       menuNewActive: false,
       showRecycle: false,
       showRecycle: false,
       sortedScripts: [],
       sortedScripts: [],
-      removing: null,
+      removing: false,
+      showHotkeys: false,
       // Speedup and deflicker for initial page load:
       // Speedup and deflicker for initial page load:
       // skip rendering the script list when starting in the editor.
       // skip rendering the script list when starting in the editor.
       canRenderScripts: !store.route.paths[1],
       canRenderScripts: !store.route.paths[1],
@@ -247,14 +284,27 @@ export default {
     'filters.showEnabledFirst': 'updateLater',
     'filters.showEnabledFirst': 'updateLater',
     'filters.viewSingleColumn': 'adjustScriptWidth',
     'filters.viewSingleColumn': 'adjustScriptWidth',
     'filters.viewTable': 'adjustScriptWidth',
     'filters.viewTable': 'adjustScriptWidth',
-    showRecycle: 'onUpdate',
+    showRecycle(value) {
+      keyboardService.setContext('showRecycle', value);
+      this.focusedIndex = -1;
+      this.onUpdate();
+    },
     scripts: 'refreshUI',
     scripts: 'refreshUI',
     'store.route.paths.1': 'onHashChange',
     'store.route.paths.1': 'onHashChange',
+    selectedScript(script) {
+      keyboardService.setContext('selectedScript', script);
+    },
+    showHotkeys(value) {
+      keyboardService.setContext('showHotkeys', value);
+    },
   },
   },
   computed: {
   computed: {
     currentSortCompare() {
     currentSortCompare() {
       return filterOptions.sort[filters.sort.value]?.compare;
       return filterOptions.sort[filters.sort.value]?.compare;
     },
     },
+    selectedScript() {
+      return this.filteredScripts[this.focusedIndex];
+    },
     message() {
     message() {
       if (this.store.loading) {
       if (this.store.loading) {
         return null;
         return null;
@@ -290,6 +340,8 @@ export default {
       const cmp = this.currentSortCompare;
       const cmp = this.currentSortCompare;
       if (cmp) scripts.sort(combinedCompare(cmp));
       if (cmp) scripts.sort(combinedCompare(cmp));
       this.sortedScripts = scripts;
       this.sortedScripts = scripts;
+      this.filteredScripts = this.search ? scripts.filter(({ $cache }) => $cache.show) : scripts;
+      this.selectScript(this.focusedIndex);
       if (!step || numFound < step) this.renderScripts();
       if (!step || numFound < step) this.renderScripts();
       else this.debouncedRender();
       else this.debouncedRender();
     },
     },
@@ -347,7 +399,7 @@ export default {
     onStateChange(active) {
     onStateChange(active) {
       this.menuNewActive = active;
       this.menuNewActive = active;
     },
     },
-    onEditScript(id) {
+    editScript(id) {
       const pathname = ['scripts', id].filter(Boolean).join('/');
       const pathname = ['scripts', id].filter(Boolean).join('/');
       if (!id && pathname === lastRoute().pathname) {
       if (!id && pathname === lastRoute().pathname) {
         window.history.back();
         window.history.back();
@@ -375,31 +427,6 @@ export default {
         }
         }
       }
       }
     },
     },
-    toggleRecycle() {
-      this.showRecycle = !this.showRecycle;
-    },
-    onRemove(id, rect) {
-      const { trash } = this.$refs;
-      if (!trash || this.removing) return;
-      const trashRect = trash.getBoundingClientRect();
-      this.removing = {
-        id,
-        animation: {
-          width: `${trashRect.width}px`,
-          height: `${trashRect.height}px`,
-          top: `${trashRect.top}px`,
-          left: `${trashRect.left}px`,
-          transform: `translate(${rect.left - trashRect.left}px,${rect.top - trashRect.top}px) scale(${rect.width / trashRect.width},${rect.height / trashRect.height})`,
-          transition: 'transform .3s',
-        },
-      };
-      setTimeout(() => {
-        this.removing.animation.transform = 'translate(0,0) scale(1,1)';
-        setTimeout(() => {
-          this.removing = null;
-        }, 300);
-      });
-    },
     async renderScripts() {
     async renderScripts() {
       if (!this.canRenderScripts) return;
       if (!this.canRenderScripts) return;
       const { length } = this.sortedScripts;
       const { length } = this.sortedScripts;
@@ -474,6 +501,51 @@ export default {
       this.numColumns = filters.viewSingleColumn ? 1
       this.numColumns = filters.viewSingleColumn ? 1
         : widths.findIndex(w => window.innerWidth < w) + 1 || widths.length + 1;
         : widths.findIndex(w => window.innerWidth < w) + 1 || widths.length + 1;
     },
     },
+    selectScript(index) {
+      index = Math.min(index, this.filteredScripts.length - 1);
+      index = Math.max(index, -1);
+      if (index !== this.focusedIndex) {
+        this.focusedIndex = index;
+      }
+    },
+    markRemove(script, removed) {
+      sendCmd('MarkRemoved', {
+        id: script.props.id,
+        removed,
+      });
+    },
+    handleActionEdit(script) {
+      this.editScript(script.props.id);
+    },
+    handleActionRemove(script) {
+      this.markRemove(script, 1);
+      this.removing = true;
+      setTimeout(() => {
+        this.removing = false;
+      }, 1000);
+    },
+    handleActionRestore(script) {
+      this.markRemove(script, 0);
+    },
+    handleActionToggle(script) {
+      sendCmd('UpdateScriptInfo', {
+        id: script.props.id,
+        config: {
+          enabled: script.config.enabled ? 0 : 1,
+        },
+      });
+    },
+    handleActionUpdate(script) {
+      sendCmd('CheckUpdate', script.props.id);
+    },
+    handleSmoothScroll(delta) {
+      if (!delta) return;
+      const el = this.$refs.scriptList;
+      el.scroll({
+        top: el.scrollTop + delta,
+        behavior: 'smooth',
+      });
+    },
   },
   },
   created() {
   created() {
     this.debouncedUpdate = debounce(this.onUpdate, 100);
     this.debouncedUpdate = debounce(this.onUpdate, 100);
@@ -492,6 +564,117 @@ export default {
       global.addEventListener('resize', this.adjustScriptWidth);
       global.addEventListener('resize', this.adjustScriptWidth);
     }
     }
     this.adjustScriptWidth();
     this.adjustScriptWidth();
+    this.disposeList = [
+      ...ua.isFirefox ? [
+        keyboardService.register('tab', () => {
+          handleTabNavigation(1);
+        }),
+        keyboardService.register('s-tab', () => {
+          handleTabNavigation(-1);
+        }),
+      ] : [],
+      ...registerHotkey(() => {
+        this.$refs.search?.focus();
+      }, [
+        ['ctrlcmd-f', conditionAll],
+        ['/', conditionNotSearch, true],
+      ]),
+      ...registerHotkey(() => {
+        this.$refs.search?.blur();
+      }, [
+        ['enter', conditionSearch],
+      ]),
+      ...registerHotkey(() => {
+        if (this.selectedScript) this.showHotkeys = !this.showHotkeys;
+      }, [
+        ['enter', `${conditionAll} && scriptFocus`],
+      ]),
+      ...registerHotkey(() => {
+        this.showHotkeys = false;
+      }, [
+        ['escape', conditionHotkeys],
+        ['q', conditionHotkeys, true],
+      ]),
+      ...registerHotkey(() => {
+        let index = this.focusedIndex;
+        if (index < 0) index = 0;
+        else index += this.numColumns;
+        if (index < this.filteredScripts.length) {
+          this.selectScript(index);
+        }
+      }, [
+        ['ctrlcmd-down', conditionAll],
+        ['down', conditionAll],
+        ['j', conditionNotSearch, true],
+      ]),
+      ...registerHotkey(() => {
+        const index = this.focusedIndex - this.numColumns;
+        if (index >= 0) {
+          this.selectScript(index);
+        }
+      }, [
+        ['ctrlcmd-up', conditionAll],
+        ['up', conditionAll],
+        ['k', conditionNotSearch, true],
+      ]),
+      ...registerHotkey(() => {
+        this.selectScript(this.focusedIndex - 1);
+      }, [
+        ['ctrlcmd-left', conditionAll],
+        ['left', conditionNotSearch],
+        ['h', conditionNotSearch, true],
+      ]),
+      ...registerHotkey(() => {
+        this.selectScript(this.focusedIndex + 1);
+      }, [
+        ['ctrlcmd-right', conditionAll],
+        ['right', conditionNotSearch],
+        ['l', conditionNotSearch, true],
+      ]),
+      ...registerHotkey(() => {
+        this.selectScript(0);
+      }, [
+        ['ctrlcmd-home', conditionAll],
+        ['g g', conditionNotSearch, true],
+      ]),
+      ...registerHotkey(() => {
+        this.selectScript(this.filteredScripts.length - 1);
+      }, [
+        ['ctrlcmd-end', conditionAll],
+        ['G', conditionNotSearch, true],
+      ]),
+      ...registerHotkey(() => {
+        this.handleActionEdit(this.selectedScript);
+      }, [
+        [scriptHotkeys.edit, conditionScriptFocused, true],
+      ]),
+      ...registerHotkey(() => {
+        this.handleActionRemove(this.selectedScript);
+      }, [
+        ['delete', conditionScriptFocused],
+        [scriptHotkeys.remove, conditionScriptFocused, true],
+      ]),
+      ...registerHotkey(() => {
+        this.handleActionUpdate(this.selectedScript);
+      }, [
+        [scriptHotkeys.update, conditionScriptFocused, true],
+      ]),
+      ...registerHotkey(() => {
+        this.handleActionToggle(this.selectedScript);
+      }, [
+        [scriptHotkeys.toggle, conditionScriptFocused, true],
+      ]),
+      ...registerHotkey(() => {
+        this.handleActionRestore(this.selectedScript);
+      }, [
+        [scriptHotkeys.restore, conditionScriptFocusedRecycle, true],
+      ]),
+    ];
+  },
+  beforeDestroy() {
+    this.disposeList?.forEach(dispose => {
+      dispose();
+    });
   },
   },
 };
 };
 </script>
 </script>
@@ -554,6 +737,7 @@ export default {
   text-decoration: none;
   text-decoration: none;
   color: var(--fill-9);
   color: var(--fill-9);
   cursor: pointer;
   cursor: pointer;
+  &:focus,
   &:hover {
   &:hover {
     color: inherit;
     color: inherit;
     background: var(--fill-0-5);
     background: var(--fill-0-5);
@@ -580,13 +764,6 @@ export default {
   &-tooltip {
   &-tooltip {
     white-space: pre-wrap;
     white-space: pre-wrap;
   }
   }
-  select {
-    /* borders are copied from inputs in common/ui/style */
-    border: 1px solid var(--fill-3);
-    &:focus {
-      border-color: var(--fill-7);
-    }
-  }
 }
 }
 .filter-sort {
 .filter-sort {
   .vl-dropdown-menu {
   .vl-dropdown-menu {
@@ -620,12 +797,15 @@ export default {
 }
 }
 
 
 .trash-animate {
 .trash-animate {
-  position: fixed;
-  background: rgba(0,0,0,.1);
-  transform-origin: top left;
+  animation: .5s linear rotate;
 }
 }
 
 
-.script.removing {
-  opacity: .2;
+@keyframes rotate {
+  0% {
+    transform: scale(1.2) rotate(0);
+  }
+  100% {
+    transform: scale(1.2) rotate(360deg);
+  }
 }
 }
 </style>
 </style>

+ 5 - 6
src/popup/style.css

@@ -66,6 +66,7 @@ footer {
 
 
 .menu-area {
 .menu-area {
   cursor: pointer;
   cursor: pointer;
+  &:focus,
   &:hover {
   &:hover {
     background: cornflowerblue;
     background: cornflowerblue;
     color: var(--bg);
     color: var(--bg);
@@ -187,9 +188,6 @@ footer {
   border-top: 1px dashed var(--fill-2);
   border-top: 1px dashed var(--fill-2);
   > * {
   > * {
     position: relative;
     position: relative;
-    &:not(:hover):not(.extras-shown) > .submenu-buttons {
-      visibility: hidden;
-    }
     .menu-item {
     .menu-item {
       padding-left: 0;
       padding-left: 0;
     }
     }
@@ -199,15 +197,15 @@ footer {
     position: absolute;
     position: absolute;
     top: 0;
     top: 0;
     right: 0;
     right: 0;
-    opacity: .5;
-    &:hover {
-      opacity: 1;
+    .focused > & {
+      opacity: .5;
     }
     }
   }
   }
   &-button {
   &-button {
     padding: .5rem;
     padding: .5rem;
     background: var(--bg);
     background: var(--bg);
     cursor: pointer;
     cursor: pointer;
+    &:focus,
     &:hover {
     &:hover {
       color: var(--bg);
       color: var(--bg);
       background: cornflowerblue;
       background: cornflowerblue;
@@ -280,6 +278,7 @@ footer {
     &:last-child {
     &:last-child {
       padding-bottom: .75rem;
       padding-bottom: .75rem;
     }
     }
+    &:focus,
     &:hover {
     &:hover {
       color: var(--bg);
       color: var(--bg);
       background: cornflowerblue;
       background: cornflowerblue;

+ 129 - 22
src/popup/views/app.vue

@@ -19,6 +19,7 @@
         :content="options.isApplied ? i18n('menuScriptEnabled') : i18n('menuScriptDisabled')"
         :content="options.isApplied ? i18n('menuScriptEnabled') : i18n('menuScriptDisabled')"
         placement="bottom"
         placement="bottom"
         align="end"
         align="end"
+        :tabIndex="tabIndex"
         @click.native="onToggle">
         @click.native="onToggle">
         <icon :name="getSymbolCheck(options.isApplied)"></icon>
         <icon :name="getSymbolCheck(options.isApplied)"></icon>
       </tooltip>
       </tooltip>
@@ -27,6 +28,7 @@
         :content="i18n('menuDashboard')"
         :content="i18n('menuDashboard')"
         placement="bottom"
         placement="bottom"
         align="end"
         align="end"
+        :tabIndex="tabIndex"
         @click.native="onManage">
         @click.native="onManage">
         <icon name="cog"></icon>
         <icon name="cog"></icon>
       </tooltip>
       </tooltip>
@@ -35,12 +37,16 @@
         :content="i18n('menuNewScript')"
         :content="i18n('menuNewScript')"
         placement="bottom"
         placement="bottom"
         align="end"
         align="end"
+        :tabIndex="tabIndex"
         @click.native="onCreateScript">
         @click.native="onCreateScript">
         <icon name="plus"></icon>
         <icon name="plus"></icon>
       </tooltip>
       </tooltip>
     </div>
     </div>
     <div class="menu" v-if="store.injectable" v-show="store.domain">
     <div class="menu" v-if="store.injectable" v-show="store.domain">
-      <div class="menu-item menu-area menu-find" @click="onFindSameDomainScripts">
+      <div
+        class="menu-item menu-area menu-find"
+        :tabIndex="tabIndex"
+        @click="onFindSameDomainScripts">
         <icon name="search"></icon>
         <icon name="search"></icon>
         <div class="flex-1" v-text="i18n('menuFindScripts')"></div>
         <div class="flex-1" v-text="i18n('menuFindScripts')"></div>
       </div>
       </div>
@@ -63,7 +69,10 @@
       }"
       }"
       :data-type="scope.name"
       :data-type="scope.name"
       :key="scope.name">
       :key="scope.name">
-      <div class="menu-item menu-area menu-group" @click="toggleMenu(scope.name)">
+      <div
+        class="menu-item menu-area menu-group"
+        :tabIndex="tabIndex"
+        @click="toggleMenu(scope.name)">
         <div class="flex-auto" v-text="scope.title" :data-totals="scope.totals" />
         <div class="flex-auto" v-text="scope.title" :data-totals="scope.totals" />
         <icon name="arrow" class="icon-collapse"></icon>
         <icon name="arrow" class="icon-collapse"></icon>
       </div>
       </div>
@@ -77,12 +86,19 @@
             removed: item.data.config.removed,
             removed: item.data.config.removed,
             'extras-shown': activeExtras === item,
             'extras-shown': activeExtras === item,
             'excludes-shown': item.excludesValue,
             'excludes-shown': item.excludesValue,
+            focused: focusedItem === item,
           }"
           }"
           class="script"
           class="script"
-          @mouseenter="message = item.name"
-          @mouseleave="message = ''">
+          @mouseover="blur"
+          @mouseenter="activeItem = item; message = item.name"
+          @mouseleave="activeItem = null; message = ''">
           <div
           <div
             class="menu-item menu-area"
             class="menu-item menu-area"
+            :tabIndex="tabIndex"
+            @focus="focusedItem = item; activeItem = item; message = item.name"
+            @blur="focusedItem = null; message = ''"
+            @mouseenter="focusedItem = item"
+            @mouseleave="focusedItem = null"
             @click="onToggleScript(item)">
             @click="onToggleScript(item)">
             <img class="script-icon" :src="item.data.safeIcon">
             <img class="script-icon" :src="item.data.safeIcon">
             <icon :name="getSymbolCheck(item.data.config.enabled)"></icon>
             <icon :name="getSymbolCheck(item.data.config.enabled)"></icon>
@@ -91,13 +107,16 @@
                  @contextmenu.exact.stop="onEditScript(item)"
                  @contextmenu.exact.stop="onEditScript(item)"
                  @mousedown.middle.exact.stop="onEditScript(item)" />
                  @mousedown.middle.exact.stop="onEditScript(item)" />
           </div>
           </div>
-          <div class="submenu-buttons">
+          <div class="submenu-buttons" v-show="activeExtras === item || activeItem === item">
             <!-- Using a standard tooltip that's shown after a delay to avoid nagging the user -->
             <!-- Using a standard tooltip that's shown after a delay to avoid nagging the user -->
-            <div class="submenu-button" @click="onEditScript(item)"
+            <div class="submenu-button" :tabIndex="tabIndex" @click="onEditScript(item)"
                  :title="i18n('buttonEditClickHint')">
                  :title="i18n('buttonEditClickHint')">
               <icon name="code"></icon>
               <icon name="code"></icon>
             </div>
             </div>
-            <div class="submenu-button" @click.stop="toggleExtras(item, $event)">
+            <div
+              class="submenu-button"
+              :tabIndex="tabIndex"
+              @click.stop="toggleExtras(item, $event)">
               <icon name="more"/>
               <icon name="more"/>
             </div>
             </div>
           </div>
           </div>
@@ -105,7 +124,7 @@
             <textarea v-model="item.excludesValue" spellcheck="false"/>
             <textarea v-model="item.excludesValue" spellcheck="false"/>
             <div>
             <div>
               <button v-text="i18n('buttonOK')" @click="onExcludeSave(item)"/>
               <button v-text="i18n('buttonOK')" @click="onExcludeSave(item)"/>
-              <button v-text="i18n('buttonCancel')" @click="item.excludesValue = null"/>
+              <button v-text="i18n('buttonCancel')" @click="onExcludeClose(item)"/>
               <!-- not using tooltip to preserve line breaks -->
               <!-- not using tooltip to preserve line breaks -->
               <details>
               <details>
                 <summary><icon name="info"/></summary>
                 <summary><icon name="info"/></summary>
@@ -125,9 +144,12 @@
               class="menu-item menu-area"
               class="menu-item menu-area"
               v-for="(cap, i) in store.commands[item.data.props.id]"
               v-for="(cap, i) in store.commands[item.data.props.id]"
               :key="i"
               :key="i"
+              :tabIndex="tabIndex"
               @click="onCommand(item.data.props.id, cap)"
               @click="onCommand(item.data.props.id, cap)"
+              @focus="message = cap"
+              @blur="message = ''"
               @mouseenter="message = cap"
               @mouseenter="message = cap"
-              @mouseleave="message = item.name">
+              @mouseleave="message = ''">
               <icon name="command" />
               <icon name="command" />
               <div class="flex-auto ellipsis" v-text="cap" />
               <div class="flex-auto ellipsis" v-text="cap" />
             </div>
             </div>
@@ -145,16 +167,17 @@
        v-if="store.currentTab && store.currentTab.incognito"
        v-if="store.currentTab && store.currentTab.incognito"
        v-text="i18n('msgIncognitoChanges')"/>
        v-text="i18n('msgIncognitoChanges')"/>
     <footer>
     <footer>
-      <span @click="onVisitWebsite" v-text="i18n('visitWebsite')" />
+      <span :tabIndex="tabIndex" @click="onVisitWebsite" v-text="i18n('visitWebsite')" />
     </footer>
     </footer>
-    <div class="message" v-if="message">
+    <div class="message" v-show="message">
       <div v-text="message"></div>
       <div v-text="message"></div>
     </div>
     </div>
     <div v-if="activeExtras" class="extras-menu" ref="extrasMenu">
     <div v-if="activeExtras" class="extras-menu" ref="extrasMenu">
-      <a v-if="activeExtras.home" :href="activeExtras.home" v-text="i18n('buttonHome')"
+      <a v-if="activeExtras.home" tabindex="0" :href="activeExtras.home" v-text="i18n('buttonHome')"
          rel="noopener noreferrer" target="_blank"/>
          rel="noopener noreferrer" target="_blank"/>
-      <div v-text="i18n('menuExclude')" @click="onExclude"/>
+      <div v-text="i18n('menuExclude')" tabindex="0" @click="onExclude"/>
       <div v-text="activeExtras.data.config.removed ? i18n('buttonRestore') : i18n('buttonRemove')"
       <div v-text="activeExtras.data.config.removed ? i18n('buttonRestore') : i18n('buttonRemove')"
+           tabindex="0"
            @click="onRemoveScript(activeExtras)"/>
            @click="onRemoveScript(activeExtras)"/>
     </div>
     </div>
   </div>
   </div>
@@ -167,6 +190,7 @@ import options from '#/common/options';
 import { getScriptName, i18n, makePause, sendCmd, sendTabCmd } from '#/common';
 import { getScriptName, i18n, makePause, sendCmd, sendTabCmd } from '#/common';
 import { autofitElementsHeight } from '#/common/ui';
 import { autofitElementsHeight } from '#/common/ui';
 import Icon from '#/common/ui/icon';
 import Icon from '#/common/ui/icon';
+import { keyboardService, isInput } from '#/common/keyboard';
 import { mutex, store } from '../utils';
 import { mutex, store } from '../utils';
 
 
 const SCRIPT_CLS = '.script';
 const SCRIPT_CLS = '.script';
@@ -187,6 +211,18 @@ options.hook((changes) => {
   }
   }
 });
 });
 
 
+function compareBy(...keys) {
+  return (a, b) => {
+    for (const key of keys) {
+      const ka = key(a);
+      const kb = key(b);
+      if (ka < kb) return -1;
+      if (ka > kb) return 1;
+    }
+    return 0;
+  };
+}
+
 export default {
 export default {
   components: {
   components: {
     Icon,
     Icon,
@@ -197,8 +233,10 @@ export default {
       store,
       store,
       options: optionsData,
       options: optionsData,
       activeMenu: 'scripts',
       activeMenu: 'scripts',
+      activeItem: null,
       activeExtras: null,
       activeExtras: null,
       message: null,
       message: null,
+      focusedItem: null,
     };
     };
   },
   },
   computed: {
   computed: {
@@ -259,6 +297,9 @@ export default {
         || ''
         || ''
       );
       );
     },
     },
+    tabIndex() {
+      return this.activeExtras ? -1 : 0;
+    },
   },
   },
   methods: {
   methods: {
     toggleMenu(name) {
     toggleMenu(name) {
@@ -266,13 +307,14 @@ export default {
     },
     },
     toggleExtras(item, evt) {
     toggleExtras(item, evt) {
       this.activeExtras = this.activeExtras === item ? null : item;
       this.activeExtras = this.activeExtras === item ? null : item;
+      keyboardService.setContext('activeExtras', this.activeExtras);
       if (this.activeExtras) {
       if (this.activeExtras) {
         item.el = evt.target.closest(SCRIPT_CLS);
         item.el = evt.target.closest(SCRIPT_CLS);
         this.$nextTick(() => {
         this.$nextTick(() => {
           const { extrasMenu } = this.$refs;
           const { extrasMenu } = this.$refs;
           extrasMenu.style.top = `${
           extrasMenu.style.top = `${
             Math.min(window.innerHeight - extrasMenu.getBoundingClientRect().height,
             Math.min(window.innerHeight - extrasMenu.getBoundingClientRect().height,
-              evt.currentTarget.getBoundingClientRect().top + 16)
+              (evt.currentTarget || evt.target).getBoundingClientRect().top + 16)
           }px`;
           }px`;
         });
         });
       }
       }
@@ -358,6 +400,10 @@ export default {
         area.focus();
         area.focus();
       });
       });
     },
     },
+    onExcludeClose(item) {
+      item.excludesValue = null;
+      this.focus(item);
+    },
     async onExcludeSave(item) {
     async onExcludeSave(item) {
       await sendCmd('UpdateScriptInfo', {
       await sendCmd('UpdateScriptInfo', {
         id: item.data.props.id,
         id: item.data.props.id,
@@ -365,19 +411,80 @@ export default {
           excludeMatch: item.excludesValue.split('\n').map(line => line.trim()).filter(Boolean),
           excludeMatch: item.excludesValue.split('\n').map(line => line.trim()).filter(Boolean),
         },
         },
       });
       });
-      item.excludesValue = null;
+      this.onExcludeClose(item);
       this.checkReload();
       this.checkReload();
     },
     },
+    navigate(dir) {
+      const { activeElement } = document;
+      const items = Array.from(this.$el.querySelectorAll('[tabindex="0"]'))
+      .map(el => ({
+        el,
+        rect: el.getBoundingClientRect(),
+      }))
+      .filter(({ rect }) => rect.width && rect.height);
+      items.sort(compareBy(item => item.rect.top, item => item.rect.left));
+      let index = items.findIndex(({ el }) => el === activeElement);
+      const findItemIndex = (step, test) => {
+        for (let i = index + step; i >= 0 && i < items.length; i += step) {
+          if (test(items[index], items[i])) return i;
+        }
+      };
+      if (index < 0) {
+        index = 0;
+      } else if (dir === 'u' || dir === 'd') {
+        const step = dir === 'u' ? -1 : 1;
+        index = findItemIndex(step, (a, b) => (a.rect.top - b.rect.top) * step < 0);
+        if (dir === 'u') {
+          while (index > 0 && items[index - 1].rect.top === items[index].rect.top) index -= 1;
+        }
+      } else {
+        const step = dir === 'l' ? -1 : 1;
+        index = findItemIndex(step, (a, b) => (a.rect.left - b.rect.left) * step < 0);
+      }
+      items[index]?.el.focus();
+    },
+    focus(item) {
+      item?.el?.querySelector('.menu-area')?.focus();
+    },
+    blur() {
+      const { activeElement } = document;
+      if (activeElement && !isInput(activeElement)) activeElement.blur();
+    },
   },
   },
   mounted() {
   mounted() {
-    // close the extras menu on Escape key
-    window.addEventListener('keydown', evt => {
-      if (this.activeExtras
-      && evt.key === 'Escape' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey && !evt.metaKey) {
-        evt.preventDefault();
+    keyboardService.enable();
+    this.disposeList = [
+      keyboardService.register('escape', () => {
         this.toggleExtras(null);
         this.toggleExtras(null);
-      }
-    });
+        this.focus(this.activeItem);
+      }, {
+        condition: 'activeExtras',
+      }),
+      keyboardService.register('up', () => {
+        this.navigate('u');
+      }, {
+        condition: '!inputFocus',
+      }),
+      keyboardService.register('down', () => {
+        this.navigate('d');
+      }, {
+        condition: '!inputFocus',
+      }),
+      keyboardService.register('left', () => {
+        this.navigate('l');
+      }, {
+        condition: '!inputFocus',
+      }),
+      keyboardService.register('right', () => {
+        this.navigate('r');
+      }, {
+        condition: '!inputFocus',
+      }),
+    ];
+  },
+  beforeDestroy() {
+    keyboardService.disable();
+    this.disposeList?.forEach(dispose => { dispose(); });
   },
   },
 };
 };
 </script>
 </script>

+ 17 - 10
yarn.lock

@@ -877,10 +877,10 @@
   dependencies:
   dependencies:
     regenerator-runtime "^0.13.4"
     regenerator-runtime "^0.13.4"
 
 
-"@babel/runtime@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.4.tgz#a6724f1a6b8d2f6ea5236dbfe58c7d7ea9c5eb99"
-  integrity sha512-UpTN5yUJr9b4EX2CnGNWIvER7Ab83ibv0pcvvHc4UOdrBI5jb8bj+32cCwPX6xu0mt2daFNjYhoi+X7beH0RSw==
+"@babel/runtime@^7.13.10":
+  version "7.13.10"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d"
+  integrity sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==
   dependencies:
   dependencies:
     regenerator-runtime "^0.13.4"
     regenerator-runtime "^0.13.4"
 
 
@@ -930,7 +930,7 @@
 "@gera2ld/locky@^0.1.1":
 "@gera2ld/locky@^0.1.1":
   version "0.1.1"
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/@gera2ld/locky/-/locky-0.1.1.tgz#b4e073fcaa09e7707ae9ebe1dcaedd28ef573cdd"
   resolved "https://registry.yarnpkg.com/@gera2ld/locky/-/locky-0.1.1.tgz#b4e073fcaa09e7707ae9ebe1dcaedd28ef573cdd"
-  integrity sha1-tOBz/KoJ53B66evh3K7dKO9XPN0=
+  integrity sha512-UDVph84c8rgRv4PzPzjiADdc0tfx0ZoH2f4cp+Vxvap0GGiLLyDR+e6RIEjVIMWMg+i+KtwpgWm4ukqy5eNxxA==
   dependencies:
   dependencies:
     commander "^5.0.0"
     commander "^5.0.0"
 
 
@@ -1125,6 +1125,13 @@
   resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
   resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
   integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==
   integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==
 
 
+"@violentmonkey/shortcut@^1.2.3":
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/@violentmonkey/shortcut/-/shortcut-1.2.3.tgz#69babec2a5228361e7496390254926289d8a3b34"
+  integrity sha512-3B7wrsH3Q9WOAPT/RCBCrWCjBf8T7Ngeo8h5voU+E2MaV+5EMNEbD0E/DZ/sPYgKfGJPKWmBPPcYsZbywWnahA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
 "@vue/component-compiler-utils@^3.1.0":
 "@vue/component-compiler-utils@^3.1.0":
   version "3.1.1"
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/@vue/component-compiler-utils/-/component-compiler-utils-3.1.1.tgz#d4ef8f80292674044ad6211e336a302e4d2a6575"
   resolved "https://registry.yarnpkg.com/@vue/component-compiler-utils/-/component-compiler-utils-3.1.1.tgz#d4ef8f80292674044ad6211e336a302e4d2a6575"
@@ -10554,12 +10561,12 @@ vue@^2.6.11:
   resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.11.tgz#76594d877d4b12234406e84e35275c6d514125c5"
   resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.11.tgz#76594d877d4b12234406e84e35275c6d514125c5"
   integrity sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ==
   integrity sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ==
 
 
-vueleton@^1.0.4:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/vueleton/-/vueleton-1.0.5.tgz#2ddee43a510c1cb1127c2758e9ea1cb81806f4fb"
-  integrity sha512-pKkJOZQEK8+Jl05b6KAA37V04JsdtdW6OYge+F8uvp3C4UKmjJr3zD4ikLpluiIm69MSCkqcBSZF/svHRlVLyA==
+vueleton@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/vueleton/-/vueleton-1.0.6.tgz#ddd7d11b47170d410b450708f2668770d0182534"
+  integrity sha512-AcFBFS7/Mz0IVh59TMUMO+uWkLvyVgamYkFWJugEK2XnGYpX32kSZ+UQ2iEQAL2HoUUZ9Q74pNS1pVzzg3OtUA==
   dependencies:
   dependencies:
-    "@babel/runtime" "^7.10.4"
+    "@babel/runtime" "^7.13.10"
 
 
 w3c-hr-time@^1.0.2:
 w3c-hr-time@^1.0.2:
   version "1.0.2"
   version "1.0.2"