Browse Source

refactor: move confirm into a new page

Gerald 8 years ago
parent
commit
28f37588eb

+ 9 - 9
package.json

@@ -21,17 +21,17 @@
     "babel-loader": "^7.1.0",
     "babel-plugin-transform-export-extensions": "^6.22.0",
     "babel-plugin-transform-runtime": "^6.23.0",
-    "babel-preset-env": "^1.5.2",
-    "babili-webpack-plugin": "^0.1.1",
+    "babel-preset-env": "^1.6.0",
+    "babili-webpack-plugin": "^0.1.2",
     "css-loader": "^0.28.4",
     "del": "^3.0.0",
     "eslint": "^4.0.0",
     "eslint-config-airbnb-base": "^11.2.0",
     "eslint-friendly-formatter": "^3.0.0",
-    "eslint-import-resolver-webpack": "^0.8.1",
+    "eslint-import-resolver-webpack": "^0.8.3",
     "eslint-loader": "^1.8.0",
     "eslint-plugin-html": "^3.0.0",
-    "eslint-plugin-import": "^2.3.0",
+    "eslint-plugin-import": "^2.6.1",
     "extract-text-webpack-plugin": "^2.1.2",
     "friendly-errors-webpack-plugin": "^1.6.1",
     "gulp": "^3.9.1",
@@ -39,16 +39,16 @@
     "gulp-svg-sprite": "^1.3.7",
     "gulp-uglify": "^3.0.0",
     "gulp-util": "^3.0.7",
-    "html-webpack-plugin": "^2.28.0",
-    "husky": "^0.13.4",
+    "html-webpack-plugin": "^2.29.0",
+    "husky": "^0.14.3",
     "js-yaml": "^3.8.4",
     "localStorage": "^1.0.3",
     "postcss-loader": "^2.0.6",
     "precss": "^2.0.0",
     "svgo": "^0.7.2",
-    "tape": "^4.6.3",
+    "tape": "^4.7.0",
     "through2": "^2.0.3",
-    "vue-loader": "^12.2.1",
+    "vue-loader": "^13.0.0",
     "vue-style-loader": "^3.0.1",
     "vue-template-compiler": "^2.3.4",
     "webpack": "^3.0.0",
@@ -66,7 +66,7 @@
   "homepage": "https://github.com/violentmonkey/violentmonkey",
   "license": "MIT",
   "dependencies": {
-    "codemirror": "^5.26.0",
+    "codemirror": "^5.27.4",
     "core-js": "^2.4.1",
     "sync-promise-lite": "^0.2.3",
     "vue": "^2.3.4",

+ 6 - 0
scripts/webpack.conf.js

@@ -9,6 +9,7 @@ const { IS_DEV, merge } = require('./utils');
 const entry = {
   'background/app': 'src/background/app.js',
   'options/app': 'src/options/app.js',
+  'confirm/app': 'src/confirm/app.js',
   'popup/app': 'src/popup/app.js',
   injected: 'src/injected/index.js',
 };
@@ -38,6 +39,11 @@ targets.push(merge(base, {
       template: 'src/options/index.html',
       chunks: ['browser', 'common', 'options/app'],
     }),
+    new HtmlWebpackPlugin({
+      filename: 'confirm/index.html',
+      template: 'src/public/index.html',
+      chunks: ['browser', 'common', 'confirm/app'],
+    }),
     new HtmlWebpackPlugin({
       filename: 'popup/index.html',
       template: 'src/public/index.html',

+ 2 - 2
src/background/utils/requests.js

@@ -233,8 +233,8 @@ export function confirmInstall(info) {
       url: info.url,
       from: info.from,
     });
-    const optionsURL = browser.runtime.getURL(browser.runtime.getManifest().options_page);
-    browser.tabs.create({ url: `${optionsURL}#confirm?id=${confirmKey}` });
+    const optionsURL = browser.runtime.getURL('/confirm/index.html');
+    browser.tabs.create({ url: `${optionsURL}#?id=${confirmKey}` });
   });
 }
 

+ 14 - 0
src/common/handlers.js

@@ -0,0 +1,14 @@
+import options from './options';
+
+const handlers = {
+  UpdateOptions(data) {
+    options.update(data);
+  },
+};
+
+browser.runtime.onMessage.addListener((res, src) => {
+  const handle = handlers[res.cmd];
+  if (handle) handle(res.data, src);
+});
+
+export default handlers;

+ 1 - 1
src/options/utils/settings.js → src/common/hook-setting.js

@@ -1,4 +1,4 @@
-import options from 'src/common/options';
+import options from './options';
 
 const hooks = {};
 

+ 15 - 0
src/common/pathinfo.js

@@ -0,0 +1,15 @@
+function parseLocation(pathInfo) {
+  const [path, qs] = pathInfo.split('?');
+  const query = (qs || '').split('&').reduce((res, seq) => {
+    if (seq) {
+      const [key, val] = seq.split('=');
+      res[decodeURIComponent(key)] = decodeURIComponent(val);
+    }
+    return res;
+  }, {});
+  return { path, query };
+}
+
+export default function getPathInfo() {
+  return parseLocation(location.hash.slice(1));
+}

+ 0 - 0
src/options/views/code.vue → src/common/ui/code.vue


+ 95 - 0
src/common/ui/dropdown.vue

@@ -0,0 +1,95 @@
+<template>
+  <div class="dropdown" :class="`dropdown-${align}`" @mouseup="onMouseUp">
+    <div class="dropdown-toggle" @click="onToggle" @focus="onFocus" @blur="onBlur">
+      <slot name="toggle"></slot>
+    </div>
+    <div class="dropdown-menu" v-show="active" @mousedown.stop>
+      <slot></slot>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  props: {
+    // If true, the dropdown menu will close on menu clicked.
+    closeAfterClick: {
+      type: Boolean,
+      default: false,
+    },
+    // If true, the dropdown menu will always open on toggle clicked.
+    toggleOnOnly: {
+      type: Boolean,
+      default: false,
+    },
+    // If true, the dropdown menu will open on toggle focused.
+    focusOpen: {
+      type: Boolean,
+      default: false,
+    },
+    // Set alignment of the dropdown menu, can be either 'left' or 'right'.
+    align: {
+      type: String,
+      default: 'left',
+    },
+  },
+  data() {
+    return {
+      active: false,
+    };
+  },
+  watch: {
+    active(active, formerActive) {
+      if (active === formerActive) return;
+      if (active) {
+        document.addEventListener('mousedown', this.onClose, false);
+      } else {
+        document.removeEventListener('mousedown', this.onClose, false);
+      }
+    },
+  },
+  methods: {
+    onToggle() {
+      this.active = !this.active;
+    },
+    onOpen() {
+      this.active = true;
+    },
+    onClose() {
+      this.active = false;
+    },
+    onFocus() {
+      if (this.focusOpen) this.onOpen();
+    },
+    onBlur() {
+      const { activeElement } = document;
+      if (activeElement !== document.body && !this.$el.contains(activeElement)) this.onClose();
+    },
+    onMouseUp() {
+      if (this.closeAfterClick) this.onClose();
+    },
+  },
+};
+</script>
+
+<style>
+.dropdown {
+  position: relative;
+  display: inline-block;
+  &-toggle {
+    cursor: pointer;
+  }
+  &-menu {
+    position: absolute;
+    top: 100%;
+    margin-top: .4rem;
+    padding: .5rem;
+    border: 1px solid #bbb;
+    background: white;
+    z-index: 10;
+    .dropdown-right & {
+      right: 0;
+    }
+  }
+}
+</style>

+ 2 - 2
src/options/views/setting-check.vue → src/common/ui/setting-check.vue

@@ -3,8 +3,8 @@
 </template>
 
 <script>
-import options from 'src/common/options';
-import { hookSetting } from 'src/options/utils';
+import options from '../options';
+import hookSetting from '../hook-setting';
 
 export default {
   props: {

+ 2 - 2
src/options/views/setting-text.vue → src/common/ui/setting-text.vue

@@ -3,8 +3,8 @@
 </template>
 
 <script>
-import options from 'src/common/options';
-import { hookSetting } from 'src/options/utils';
+import options from '../options';
+import hookSetting from '../hook-setting';
 
 export default {
   props: {

+ 16 - 0
src/common/ui/style/index.js

@@ -0,0 +1,16 @@
+import options from '../../options';
+import './style.css';
+
+let style;
+options.hook(changes => {
+  if ('customCSS' in changes) {
+    const { customCSS } = changes;
+    if (customCSS && !style) {
+      style = document.createElement('style');
+      document.head.appendChild(style);
+    }
+    if (customCSS || style) {
+      style.textContent = customCSS;
+    }
+  }
+});

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

@@ -0,0 +1,174 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+html {
+  height: 100%;
+  font: 14px menu;
+}
+
+body {
+  height: 100%;
+  font-size: 1rem;
+  font-family: "PingFang SC", STHeiti, "Microsoft YaHei", sans-serif;
+}
+
+h1 {
+  font-size: 1.5rem;
+}
+h1, h2 {
+  margin-top: .5em;
+  margin-bottom: .5em;
+}
+button {
+  padding: 0 .5rem;
+  font-size: 1rem;
+  line-height: 1.5;
+  background: white;
+  cursor: pointer;
+  border: 1px solid #bbb;
+  &:hover {
+    border-color: #bbb;
+  }
+  > .icon {
+    display: block;
+    height: 2rem;
+  }
+}
+a {
+  color: dodgerblue;
+}
+hr {
+  border: none;
+  border-top: 1px solid darkgray;
+}
+input[disabled] ~ * {
+  color: gray;
+}
+input[type=text] {
+  display: block;
+  width: 100%;
+}
+textarea {
+  display: block;
+  width: 100%;
+}
+code {
+  background: #F7E999;
+}
+
+:focus {
+  outline: none;
+}
+
+.icon {
+  width: 1rem;
+  height: 1rem;
+}
+
+svg path {
+  fill: currentColor;
+}
+
+.pull-left {
+  float: left;
+}
+.pull-right {
+  float: right;
+}
+.inline-block {
+  display: inline-block;
+}
+.flex {
+  display: flex;
+}
+.flex-col {
+  flex-direction: column;
+}
+.flex-auto {
+  flex: auto;
+  .flex-col > & {
+    height: 0;
+  }
+}
+.pos-rel {
+  position: relative;
+}
+.mr-1 {
+  margin-right: .5em;
+}
+.mt-1 {
+  margin-top: .5em;
+}
+.mb-1 {
+  margin-bottom: .5em;
+}
+.mb-2 {
+  margin-bottom: 1em;
+}
+.px-2 {
+  margin-left: 1em;
+  margin-right: 1em;
+}
+.h-100 {
+  height: 100%;
+}
+.w-1 {
+  width: 4em;
+}
+.fixed-full {
+  position: fixed;
+}
+.abs-full {
+  position: absolute;
+}
+.fixed-full,
+.abs-full {
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+}
+.ellipsis {
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+
+.frame {
+  background: #f0f0f0;
+  &-block {
+    padding: .5rem;
+  }
+}
+
+.editor-code,
+.editor-code .CodeMirror {
+  height: 100%;
+  /* Use `Courier New` to ensure `&nbsp;` has the same width as an original space. */
+  font-family: Courier New, Courier, monospace;
+}
+
+.CodeMirror-foldmarker {
+  color: blue;
+  text-shadow: #b9f 1px 1px 2px, #b9f -1px -1px 2px, #b9f 1px -1px 2px, #b9f -1px 1px 2px;
+  font-family: arial;
+  line-height: .3;
+  cursor: pointer;
+}
+.CodeMirror-foldgutter {
+  width: .7em;
+}
+.CodeMirror-foldgutter-open,
+.CodeMirror-foldgutter-folded {
+  color: #555;
+  cursor: pointer;
+}
+.CodeMirror-foldgutter-open:after {
+  content: "\25BE";
+}
+.CodeMirror-foldgutter-folded:after {
+  content: "\25B8";
+}

+ 16 - 0
src/confirm/app.js

@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import 'src/common/browser';
+import { i18n } from 'src/common';
+import 'src/common/handlers';
+import options from 'src/common/options';
+import 'src/common/ui/style';
+import App from './views/app';
+
+Vue.prototype.i18n = i18n;
+document.title = `${i18n('labelInstall')} - ${i18n('extName')}`;
+
+options.ready(() => {
+  new Vue({
+    render: h => h(App),
+  }).$mount('#app');
+});

+ 20 - 16
src/options/views/confirm.vue → src/confirm/views/app.vue

@@ -1,10 +1,10 @@
 <template>
-  <div class="flex flex-col h-100">
+  <div class="frame flex flex-col h-100">
     <div class="frame-block">
       <div class="buttons pull-right">
-        <div v-dropdown class="confirm-options dropdown-right">
-          <button dropdown-toggle v-text="i18n('buttonInstallOptions')"></button>
-          <div class="dropdown-menu options-panel" @mousedown.stop>
+        <vm-dropdown class="confirm-options" align="right">
+          <button slot="toggle" v-text="i18n('buttonInstallOptions')"></button>
+          <div class="options-panel">
             <label>
               <setting-check name="closeAfterInstall" @change="checkClose" />
               <span v-text="i18n('installOptionClose')"></span>
@@ -14,7 +14,7 @@
               <span v-text="i18n('installOptionTrack')"></span>
             </label>
           </div>
-        </div>
+        </vm-dropdown>
         <button v-text="i18n('buttonConfirmInstallation')"
         :disabled="!installable" @click="installScript"></button>
         <button v-text="i18n('buttonClose')" @click="close"></button>
@@ -33,20 +33,22 @@
 import { sendMessage, zfill, request, buffer2string, isRemote, getFullUrl } from 'src/common';
 import options from 'src/common/options';
 import initCache from 'src/common/cache';
-import VmCode from './code';
-import { store } from '../utils';
-import SettingCheck from './setting-check';
+import VmCode from 'src/common/ui/code';
+import VmDropdown from 'src/common/ui/dropdown';
+import SettingCheck from 'src/common/ui/setting-check';
+import getPathInfo from 'src/common/pathinfo';
 
 const cache = initCache({});
+const { query } = getPathInfo();
 
 export default {
   components: {
+    VmDropdown,
     VmCode,
     SettingCheck,
   },
   data() {
     return {
-      store,
       installable: false,
       dependencyOK: false,
       closeAfterInstall: options.get('closeAfterInstall'),
@@ -59,9 +61,6 @@ export default {
     };
   },
   computed: {
-    query() {
-      return this.store.route.query;
-    },
     isLocal() {
       return !isRemote(this.info.url);
     },
@@ -70,7 +69,7 @@ export default {
     this.message = this.i18n('msgLoadingData');
     this.loadInfo()
     .then(() => {
-      const id = this.store.route.query.id;
+      const id = query.id;
       this.guard = setInterval(() => {
         sendMessage({
           cmd: 'CacheHit',
@@ -94,7 +93,7 @@ export default {
   },
   methods: {
     loadInfo() {
-      const id = this.store.route.query.id;
+      const id = query.id;
       return sendMessage({
         cmd: 'CacheLoad',
         data: `confirm-${id}`,
@@ -237,8 +236,13 @@ export default {
 </script>
 
 <style>
-.confirm-options .dropdown-menu {
-  width: 13rem;
+.confirm-options {
+  label {
+    display: block;
+  }
+  .dropdown-menu {
+    width: 13rem;
+  }
 }
 .confirm-url {
   float: left;

+ 5 - 44
src/options/app.js

@@ -3,6 +3,9 @@ import 'src/common/browser';
 import 'src/common/sprite';
 import { sendMessage, i18n, getLocaleString } from 'src/common';
 import options from 'src/common/options';
+import getPathInfo from 'src/common/pathinfo';
+import handlers from 'src/common/handlers';
+import 'src/common/ui/style';
 import { store, features } from './utils';
 import App from './views/app';
 
@@ -15,45 +18,18 @@ Object.assign(store, {
   sync: [],
   route: null,
 });
-const handlers = {
-  UpdateOptions(data) {
-    options.update(data);
-  },
-};
-browser.runtime.onMessage.addListener(res => {
-  const handle = handlers[res.cmd];
-  if (handle) handle(res.data);
-});
 zip.workerScriptsPath = '/public/lib/zip.js/';
 initialize();
 
-function parseLocation(pathInfo) {
-  const [path, qs] = pathInfo.split('?');
-  const query = (qs || '').split('&').reduce((res, seq) => {
-    if (seq) {
-      const [key, val] = seq.split('=');
-      res[decodeURIComponent(key)] = decodeURIComponent(val);
-    }
-    return res;
-  }, {});
-  return { path, query };
-}
 function loadHash() {
-  const route = parseLocation(location.hash.slice(1));
-  store.route = route;
-  if (!['', 'confirm'].includes(route.path)) location.hash = '';
+  store.route = getPathInfo();
 }
 
 function initialize() {
   document.title = i18n('extName');
-  initCustomCSS();
   window.addEventListener('hashchange', loadHash, false);
   loadHash();
-  const initializers = {
-    '': initMain,
-  };
-  const init = initializers[store.route.path];
-  if (init) init();
+  initMain();
   options.ready(() => {
     new Vue({
       render: h => h(App),
@@ -121,18 +97,3 @@ function initMain() {
     },
   });
 }
-function initCustomCSS() {
-  let style;
-  options.hook(changes => {
-    if ('customCSS' in changes) {
-      const { customCSS } = changes;
-      if (customCSS && !style) {
-        style = document.createElement('style');
-        document.head.appendChild(style);
-      }
-      if (customCSS || style) {
-        style.textContent = customCSS;
-      }
-    }
-  });
-}

+ 0 - 181
src/options/style.css

@@ -1,140 +1,6 @@
-* {
-  margin: 0;
-  padding: 0;
-  box-sizing: border-box;
-}
-:focus {
-  outline: none;
-}
 html {
-  height: 100%;
-  font: 14px menu;
   background: #f0f0f0;
 }
-body {
-  height: 100%;
-  font-size: 1em;
-}
-h1 {
-  font-size: 1.5rem;
-}
-h1, h2 {
-  margin-top: .5em;
-  margin-bottom: .5em;
-}
-button {
-  padding: 0 .5rem;
-  font-size: 1rem;
-  line-height: 1.5;
-  background: white;
-  border: 1px solid #bbb;
-  cursor: pointer;
-}
-a {
-  color: dodgerblue;
-}
-hr {
-  border: none;
-  border-top: 1px solid darkgray;
-}
-input[disabled] ~ * {
-  color: gray;
-}
-input[type=text] {
-  display: block;
-  width: 100%;
-}
-textarea {
-  display: block;
-  width: 100%;
-}
-
-.pull-left {
-  float: left;
-}
-.pull-right {
-  float: right;
-}
-.inline-block {
-  display: inline-block;
-}
-.flex {
-  display: flex;
-}
-.flex-col {
-  flex-direction: column;
-}
-.flex-auto {
-  flex: auto;
-  .flex-col > & {
-    height: 0;
-  }
-}
-.pos-rel {
-  position: relative;
-}
-.mr-1 {
-  margin-right: .5em;
-}
-.mt-1 {
-  margin-top: .5em;
-}
-.mb-1 {
-  margin-bottom: .5em;
-}
-.mb-2 {
-  margin-bottom: 1em;
-}
-.px-2 {
-  margin-left: 1em;
-  margin-right: 1em;
-}
-.h-100 {
-  height: 100%;
-}
-.w-1 {
-  width: 4em;
-}
-.fixed-full {
-  position: fixed;
-}
-.abs-full {
-  position: absolute;
-}
-.fixed-full,
-.abs-full {
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-}
-.ellipsis {
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  overflow: hidden;
-}
-.dropdown {
-  position: relative;
-  display: inline-block;
-  &-menu {
-    position: absolute;
-    top: 100%;
-    margin-top: .4rem;
-    padding: .5rem;
-    border: 1px solid #bbb;
-    background: white;
-    z-index: 10;
-    .dropdown-right & {
-      right: 0;
-    }
-  }
-  &:not(.show) .dropdown-menu {
-    display: none;
-  }
-}
-[dropdown-toggle] {
-  cursor: pointer;
-}
 
 .main {
   position: relative;
@@ -170,8 +36,6 @@ aside img {
 .sidemenu > a:hover {
   color: black;
 }
-.content {
-}
 #currentLang {
   color: green;
   font-weight: bold;
@@ -197,21 +61,9 @@ section {
     margin-bottom: .3rem;
   }
 }
-code {
-  background: #F7E999;
-}
-.frame-block {
-  padding: .5rem;
-}
 .options-panel label {
   display: block;
 }
-.editor-code,
-.editor-code .CodeMirror {
-  height: 100%;
-  /* Use `Courier New` to ensure `&nbsp;` has the same width as an original space. */
-  font-family: Courier New, Courier, monospace;
-}
 .script {
   position: relative;
   margin: .6rem;
@@ -276,9 +128,6 @@ code {
   background: wheat;
   z-index: 9;
 }
-.edit {
-  background: #f0f0f0;
-}
 .feature {
   &-text {
     position: relative;
@@ -298,14 +147,6 @@ code {
     }
   }
 }
-.icon {
-  width: 1rem;
-  height: 1rem;
-  vertical-align: middle;
-}
-svg path {
-  fill: currentColor;
-}
 
 .tab {
   position: relative;
@@ -316,25 +157,3 @@ svg path {
   border-left: 1px solid darkgray;
   border-right: 1px solid darkgray;
 }
-
-.CodeMirror-foldmarker {
-  color: blue;
-  text-shadow: #b9f 1px 1px 2px, #b9f -1px -1px 2px, #b9f 1px -1px 2px, #b9f -1px 1px 2px;
-  font-family: arial;
-  line-height: .3;
-  cursor: pointer;
-}
-.CodeMirror-foldgutter {
-  width: .7em;
-}
-.CodeMirror-foldgutter-open,
-.CodeMirror-foldgutter-folded {
-  color: #555;
-  cursor: pointer;
-}
-.CodeMirror-foldgutter-open:after {
-  content: "\25BE";
-}
-.CodeMirror-foldgutter-folded:after {
-  content: "\25B8";
-}

+ 0 - 81
src/options/utils/dropdown.js

@@ -1,81 +0,0 @@
-/*
- * @usage
- *
- * <div v-dropdown="{autoClose: true}">
- *   <button dropdown-toggle>Dropdown</button>
- *   <div class="dropdown-menu">
- *     This is the dropdown menu.
- *   </div>
- * </div>
- *
- * Options:
- * - autoClose: Boolean
- *   Whether to close dropdown menu after being clicked.
- * - click: 'toggle' (default) | 'open' | false
- *   If 'toggle', the menu will be toggled on click.
- *   If 'open', the menu will keep open on click.
- *   Otherwise ignored.
- * - focus: 'open' | false (default)
- *   If 'open', keep open when focused.
- *   Otherwise ignored.
- * - active: 'show'
- *   The class name to be added when active.
- *   Default value is consistent with Bootstrap v4.
- */
-import Vue from 'vue';
-
-const defaults = {
-  autoClose: false,
-  click: 'toggle',
-  focus: false,
-  active: 'show',
-};
-
-export default defaults;
-
-Vue.directive('dropdown', {
-  bind(el, binding) {
-    let isOpen = false;
-    const toggle = el.querySelector('[dropdown-toggle]');
-    const { autoClose, click, focus, active } = Object.assign({}, defaults, binding.value);
-    if (click === 'toggle') {
-      toggle.addEventListener('click', onToggle, false);
-    } else if (click === 'open') {
-      toggle.addEventListener('click', onOpen, false);
-    }
-    if (focus === 'open') {
-      toggle.addEventListener('focus', onOpen, false);
-      toggle.addEventListener('blur', onBlur, false);
-    }
-    if (autoClose) el.addEventListener('mouseup', doClose, false);
-    el.classList.add('dropdown');
-    function doClose() {
-      if (!isOpen) return;
-      isOpen = false;
-      el.classList.remove(active);
-      document.removeEventListener('mousedown', onClose, false);
-    }
-    function onClose(e) {
-      if (e && el.contains(e.target)) return;
-      doClose();
-    }
-    function onOpen() {
-      if (isOpen) return;
-      isOpen = true;
-      el.classList.add(active);
-      document.addEventListener('mousedown', onClose, false);
-    }
-    function onToggle() {
-      if (isOpen) onClose();
-      else onOpen();
-    }
-    function onBlur() {
-      setTimeout(() => {
-        const activeEl = document.activeElement;
-        if (activeEl !== document.body && !el.contains(activeEl)) {
-          doClose();
-        }
-      });
-    }
-  },
-});

+ 1 - 3
src/options/utils/index.js

@@ -1,10 +1,8 @@
 import Vue from 'vue';
-import './dropdown';
+import 'src/common/ui/dropdown';
 import resetFeatures from './features';
 import Message from '../views/message';
 
-export hookSetting from './settings';
-
 export const store = {
   messages: null,
 };

+ 37 - 7
src/options/views/app.vue

@@ -1,14 +1,44 @@
+<template>
+  <div class="main">
+    <aside>
+      <img src="/public/images/icon128.png">
+      <h1 v-text="i18n('extName')"></h1>
+      <hr>
+      <div class=sidemenu>
+        <a href="#?t=Installed" :class="{active: tab === 'Installed'}" v-text="i18n('sideMenuInstalled')"></a>
+        <a href="#?t=Settings" :class="{active: tab === 'Settings'}" v-feature="'settings'">
+          <span v-text="i18n('sideMenuSettings')" class="feature-text"></span>
+        </a>
+        <a href="#?t=About" :class="{active: tab === 'About'}" v-text="i18n('sideMenuAbout')"></a>
+      </div>
+    </aside>
+    <component :is="tab" class="tab"></component>
+  </div>
+</template>
+
 <script>
 import { store } from '../utils';
-import Main from './main';
-import Confirm from './confirm';
+import Installed from './tab-installed';
+import Settings from './tab-settings';
+import About from './tab-about';
+
+const components = {
+  Installed,
+  Settings,
+  About,
+};
 
 export default {
-  render(h) {
-    return h({
-      '': Main,
-      confirm: Confirm,
-    }[store.route.path]);
+  components,
+  data() {
+    return store;
+  },
+  computed: {
+    tab() {
+      let tab = this.route.query.t;
+      if (!components[tab]) tab = 'Installed';
+      return tab;
+    },
   },
 };
 </script>

+ 2 - 2
src/options/views/edit/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="edit flex flex-col fixed-full">
+  <div class="frame flex flex-col fixed-full">
     <div class="flex edit-header">
       <h2 v-text="i18n('labelScriptEditor')"></h2>
       <div class="flex-auto pos-rel px-2">
@@ -64,8 +64,8 @@
 <script>
 import CodeMirror from 'codemirror';
 import { i18n, debounce, sendMessage, noop } from 'src/common';
+import VmCode from 'src/common/ui/code';
 import { showMessage } from '../../utils';
-import VmCode from '../code';
 import VmSettings from './settings';
 import Tooltip from '../tooltip';
 

+ 0 - 44
src/options/views/main.vue

@@ -1,44 +0,0 @@
-<template>
-  <div class="main">
-    <aside>
-      <img src="/public/images/icon128.png">
-      <h1 v-text="i18n('extName')"></h1>
-      <hr>
-      <div class=sidemenu>
-        <a href="#?t=Installed" :class="{active: tab === 'Installed'}" v-text="i18n('sideMenuInstalled')"></a>
-        <a href="#?t=Settings" :class="{active: tab === 'Settings'}" v-feature="'settings'">
-          <span v-text="i18n('sideMenuSettings')" class="feature-text"></span>
-        </a>
-        <a href="#?t=About" :class="{active: tab === 'About'}" v-text="i18n('sideMenuAbout')"></a>
-      </div>
-    </aside>
-    <component :is="tab" class="tab"></component>
-  </div>
-</template>
-
-<script>
-import { store } from '../utils';
-import Installed from './tab-installed';
-import Settings from './tab-settings';
-import About from './tab-about';
-
-const components = {
-  Installed,
-  Settings,
-  About,
-};
-
-export default {
-  components,
-  data() {
-    return store;
-  },
-  computed: {
-    tab() {
-      let tab = this.route.query.t;
-      if (!components[tab]) tab = 'Installed';
-      return tab;
-    },
-  },
-};
-</script>

+ 9 - 9
src/options/views/tab-installed.vue

@@ -2,17 +2,15 @@
   <div class="tab-installed">
     <header class="flex">
       <div class="flex-auto">
-        <div v-dropdown="{autoClose: true}">
-          <button dropdown-toggle>
+        <vm-dropdown :closeAfterClick="true">
+          <button slot="toggle">
             <svg class="icon"><use xlink:href="#plus" /></svg>
           </button>
-          <div class="dropdown-menu">
-            <a href="#" v-text="i18n('buttonNew')" @click.prevent="newScript"></a>
-            <a v-text="i18n('installFrom', 'OpenUserJS')" href="https://openuserjs.org/" target="_blank"></a>
-            <a v-text="i18n('installFrom', 'GreasyFork')" href="https://greasyfork.org/scripts" target="_blank"></a>
-            <a href="#" v-text="i18n('buttonInstallFromURL')" @click.prevent="installFromURL"></a>
-          </div>
-        </div>
+          <a href="#" v-text="i18n('buttonNew')" @click.prevent="newScript"></a>
+          <a v-text="i18n('installFrom', 'OpenUserJS')" href="https://openuserjs.org/" target="_blank"></a>
+          <a v-text="i18n('installFrom', 'GreasyFork')" href="https://greasyfork.org/scripts" target="_blank"></a>
+          <a href="#" v-text="i18n('buttonInstallFromURL')" @click.prevent="installFromURL"></a>
+        </vm-dropdown>
         <tooltip :title="i18n('buttonUpdateAll')" placement="down">
           <button @click="updateAll">
             <svg class="icon"><use xlink:href="#refresh" /></svg>
@@ -39,6 +37,7 @@
 
 <script>
 import { i18n, sendMessage, noop, debounce } from 'src/common';
+import VmDropdown from 'src/common/ui/dropdown';
 import Item from './script-item';
 import Edit from './edit';
 import { store, showMessage } from '../utils';
@@ -49,6 +48,7 @@ export default {
     Item,
     Edit,
     Tooltip,
+    VmDropdown,
   },
   data() {
     return {

+ 1 - 1
src/options/views/tab-settings/index.vue

@@ -38,12 +38,12 @@
 
 <script>
 import { sendMessage } from 'src/common';
+import SettingCheck from 'src/common/ui/setting-check';
 import VmImport from './vm-import';
 import VmExport from './vm-export';
 import VmSync from './vm-sync';
 import VmBlacklist from './vm-blacklist';
 import VmCss from './vm-css';
-import SettingCheck from '../setting-check';
 
 export default {
   components: {

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

@@ -16,7 +16,7 @@
 import { i18n, sendMessage } from 'src/common';
 import options from 'src/common/options';
 import { showMessage } from 'src/options/utils';
-import SettingText from '../setting-text';
+import SettingText from 'src/common/ui/setting-text';
 
 export default {
   components: {

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

@@ -13,7 +13,7 @@
 import { i18n } from 'src/common';
 import options from 'src/common/options';
 import { showMessage } from 'src/options/utils';
-import SettingText from '../setting-text';
+import SettingText from 'src/common/ui/setting-text';
 
 export default {
   components: {

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

@@ -21,8 +21,8 @@
 import { sendMessage, getLocaleString } from 'src/common';
 import options from 'src/common/options';
 import { isFirefox } from 'src/common/ua';
+import SettingCheck from 'src/common/ui/setting-check';
 import { store } from '../../utils';
-import SettingCheck from '../setting-check';
 
 /**
  * Note:

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

@@ -15,8 +15,8 @@
 <script>
 import { i18n, sendMessage } from 'src/common';
 import options from 'src/common/options';
+import SettingCheck from 'src/common/ui/setting-check';
 import { showMessage } from '../../utils';
-import SettingCheck from '../setting-check';
 
 export default {
   components: {

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

@@ -27,8 +27,8 @@
 <script>
 import { sendMessage } from 'src/common';
 import options from 'src/common/options';
+import SettingCheck from 'src/common/ui/setting-check';
 import { store } from '../../utils';
-import SettingCheck from '../setting-check';
 
 const SYNC_CURRENT = 'sync.current';
 const syncConfig = {

+ 3 - 9
src/popup/app.js

@@ -1,8 +1,9 @@
 import Vue from 'vue';
 import 'src/common/browser';
 import 'src/common/sprite';
-import options from 'src/common/options';
 import { i18n, sendMessage } from 'src/common';
+import handlers from 'src/common/handlers';
+import 'src/common/ui/style';
 import App from './views/app';
 import { store } from './utils';
 
@@ -12,7 +13,7 @@ new Vue({
   render: h => h(App),
 }).$mount('#app');
 
-const handlers = {
+Object.assign(handlers, {
   SetPopup(data, src) {
     if (store.currentTab.id !== src.tab.id) return;
     store.commands = data.menus;
@@ -24,13 +25,6 @@ const handlers = {
       store.scripts = scripts;
     });
   },
-  UpdateOptions(data) {
-    options.update(data);
-  },
-};
-browser.runtime.onMessage.addListener((req, src) => {
-  const func = handlers[req.cmd];
-  if (func) func(req.data, src);
 });
 
 browser.tabs.query({ currentWindow: true, active: true })

+ 0 - 17
src/popup/style.css

@@ -1,23 +1,6 @@
-* {
-  margin: 0;
-  padding: 0;
-  box-sizing: border-box;
-}
-html {
-  font: 14px menu;
-}
 body {
   min-width: 20em;
   padding: 1em;
-  font-size: 1em;
-}
-.icon {
-  width: 1rem;
-  height: 1rem;
-  vertical-align: bottom;
-}
-svg path {
-  fill: currentColor;
 }
 
 .logo {