Browse Source

feat: CodeMirror theme option (#1362)

tophf 4 years ago
parent
commit
8ae1b776d8

+ 7 - 5
src/_locales/en/messages.yml

@@ -13,6 +13,9 @@ buttonConfirmReinstallation:
 buttonDisable:
   description: Button to disable a script.
   message: Disable
+buttonDownloadThemes:
+  description: Button in Violentmonkey settings for editor.
+  message: Download themes
 buttonEdit:
   description: Button to edit a script.
   message: Edit
@@ -120,11 +123,7 @@ descEditorOptions:
     <code>{"indentUnit":2, "smartIndent":true}</code> however note that some of
     them may not work in Violentmonkey. See <a
     href="https://codemirror.net/doc/manual.html#config" target="_blank"
-    rel="noopener noreferrer">full list</a>. To use a custom CodeMirror theme,
-    specify its file name like <code>"theme": "3024-day"</code> here and paste
-    the <a href="https://github.com/codemirror/CodeMirror/tree/master/theme"
-    target="_blank" rel="noopener noreferrer">actual theme's CSS</a> in "Custom
-    Style" input below.
+    rel="noopener noreferrer">full list</a>.
 editHelpDocumention:
   description: Label in the editor help tab for the documentation link.
   message: 'Documentation on userscript metadata block and <code>GM</code> API:'
@@ -484,6 +483,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Username: '
+labelTheme:
+  description: Label for the visual theme option.
+  message: 'Theme: '
 labelTranslator:
   description: Label of translator.
   message: 'Translator: '

+ 2 - 0
src/common/options-defaults.js

@@ -57,6 +57,8 @@ export default {
     tabSize: 2,
     undoDepth: 200,
   },
+  editorTheme: '',
+  editorThemeName: null,
   editorWindow: false, // whether popup opens editor in a new window
   editorWindowPos: {}, // { left, top, width, height }
   editorWindowSimple: true, // whether to open a simplified popup or a normal browser window

+ 7 - 0
src/common/ui/code.vue

@@ -506,10 +506,12 @@ export default {
   },
   mounted() {
     let userOpts = options.get('editor');
+    const theme = options.get('editorThemeName');
     const internalOpts = this.cmOptions || {};
     const opts = {
       ...cmDefaults,
       ...userOpts,
+      ...theme && { theme },
       ...internalOpts, // internal options passed via `props` have the highest priority
       mode: this.mode || cmDefaults.mode,
     };
@@ -548,6 +550,11 @@ export default {
       });
       this.$watch('search.options', searchAgain, { deep: true });
     });
+    hookSetting('editorThemeName', val => {
+      if (val != null && val !== this.cm.options.theme) {
+        this.cm.setOption('theme', val);
+      }
+    });
   },
   beforeDestroy() {
     this.onActive(false);

+ 22 - 9
src/common/ui/style/index.js

@@ -2,22 +2,31 @@ import options from '../../options';
 import './style.css';
 
 let style;
+let styleTheme;
+const THEME_KEY = 'editorTheme';
 const CACHE_KEY = 'cacheCustomCSS';
 
-const setStyle = (css) => {
-  if (css && !style) {
-    style = document.createElement('style');
-    document.documentElement.appendChild(style);
+const setStyle = (css, elem) => {
+  if (css && !elem) {
+    elem = document.createElement('style');
+    document.documentElement.appendChild(elem);
   }
-  if (css || style) {
+  if (css || elem) {
     css = css || '';
-    style.textContent = css;
+    elem.textContent = css;
     try {
       localStorage.setItem(CACHE_KEY, css);
     } catch {
       // ignore
     }
   }
+  return elem;
+};
+
+const setTheme = (css) => {
+  if (!global.location.pathname.startsWith('/popup')) {
+    styleTheme = setStyle(css ?? options.get(THEME_KEY), styleTheme);
+  }
 };
 
 // In some versions of Firefox, `localStorage` is not allowed to be accessed
@@ -28,9 +37,13 @@ try {
   // ignore
 }
 
+options.ready.then(setTheme);
 options.hook((changes) => {
-  if ('customCSS' in changes) {
-    const { customCSS } = changes;
-    setStyle(customCSS);
+  let v;
+  if ((v = changes[THEME_KEY]) != null) {
+    setTheme(v);
+  }
+  if ((v = changes.customCSS) != null) {
+    style = setStyle(v, style);
   }
 });

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

@@ -265,6 +265,12 @@ li {
 .flex-1 {
   flex: 1;
 }
+.center-items {
+  align-items: center;
+}
+.stretch-self {
+  align-self: stretch;
+}
 .pos-rel {
   position: relative;
 }

+ 100 - 5
src/options/views/tab-settings/vm-editor.vue

@@ -1,7 +1,19 @@
 <template>
   <section>
     <h3 v-text="i18n('labelEditor')"></h3>
-    <p v-html="i18n('descEditorOptions')" />
+    <div class="mr-1c flex center-items">
+      <span v-text="i18n('labelTheme')"/>
+      <select v-model="theme" :disabled="busy" :title="css">
+        <option :value="DEFAULT" v-text="i18n('labelRunAtDefault')"/>
+        <option value="" v-text="i18n('labelBadgeNone')"/>
+        <option v-if="!themes && theme && theme !== DEFAULT" v-text="theme" data-active/>
+        <option v-for="(name, i) in themes" :key="`th:${i}`" v-text="name"/>
+      </select>
+      <button @click="getThemes" :disabled="busy" v-text="i18n('buttonDownloadThemes')"/>
+      <a :href="ghURL" target="_blank">&nearr;</a>
+      <p v-text="error"/>
+    </div>
+    <p v-html="i18n('descEditorOptions')" class="my-1"/>
     <setting-text name="editor" ref="editor" :json="true" :has-reset="true" @save="onSave">
       <button v-text="i18n('buttonShowEditorState')" @click="toggleStateHint"/>
     </setting-text>
@@ -11,20 +23,103 @@
 
 <script>
 import options from '#/common/options';
+import hookSetting from '#/common/hook-setting';
+import storage from '#/common/storage';
 import { showMessage } from '#/common/ui';
 import SettingText from '#/common/ui/setting-text';
 
+const keyThemeCSS = 'editorTheme';
+const keyThemeNAME = 'editorThemeName';
+const keyThemeNAMES = 'editorThemeNames';
+const gh = 'github.com';
+const ghREPO = 'codemirror/CodeMirror';
+const ghBRANCH = 'master';
+const ghPATH = 'theme';
+const ghURL = `https://${gh}/${ghREPO}/tree/${ghBRANCH}/${ghPATH}`;
+const DEFAULT = 'default';
+const createData = () => ({
+  hint: null,
+  busy: false,
+  error: null,
+  css: null,
+  theme: DEFAULT,
+  themes: [],
+  DEFAULT,
+  ghURL,
+});
+const previewLINES = 20;
+const previewLENGTH = 100;
+const makeTextPreview = css => (
+  css
+    ? css.split('\n', previewLINES + 1).map((s, i) => (
+      i === previewLINES && (
+        '...'
+      ) || s.length > previewLENGTH && (
+        `${s.slice(0, previewLENGTH)}...`
+      ) || s
+    )).join('\n')
+    : null
+);
+
 export default {
-  data() {
-    return { hint: null };
-  },
+  data: createData,
   components: {
     SettingText,
   },
-  mounted() {
+  beforeDestroy() {
+    this.revokers.forEach(revoke => revoke());
+    this.revokers = null;
+  },
+  async mounted() {
     this.$refs.editor.$el.addEventListener('dblclick', this.toggleBoolean);
+    if (!this.revokers) {
+      [this.themes] = await Promise.all([
+        storage.base.getOne(keyThemeNAMES),
+        options.ready,
+      ]);
+      this.css = makeTextPreview(options.get(keyThemeCSS));
+      this.revokers = [
+        ['theme', keyThemeNAME],
+      ].map(([prop, opt]) => {
+        const setValue = val => { this[prop] = val ?? createData()[prop]; };
+        setValue(options.get(opt));
+        return hookSetting(opt, setValue);
+      });
+      this.$watch('theme', async val => {
+        const url = val && val !== DEFAULT
+          && `https://raw.githubusercontent.com/${ghREPO}/${ghBRANCH}/${ghPATH}/${val}.css`;
+        const css = url && await this.fetch(url);
+        options.set(keyThemeNAME, !url || css ? val : DEFAULT);
+        options.set(keyThemeCSS, css || '');
+        this.css = makeTextPreview(css);
+      });
+    }
   },
   methods: {
+    async fetch(url, method = 'text') {
+      const el = document.activeElement;
+      this.busy = true;
+      try {
+        const res = await (await fetch(url))[method]();
+        this.error = null;
+        return res;
+      } catch (e) {
+        this.error = e.message || e.code || `${e}`;
+      } finally {
+        this.busy = false;
+        this.$nextTick(() => el?.focus());
+      }
+    },
+    async getThemes() {
+      const apiThemesUrl = `https://api.${gh}/repos/${ghREPO}/contents/${ghPATH}`;
+      const themes = (await this.fetch(apiThemesUrl, 'json'))
+      ?.map(file => /[-\w]+\.css$/.test(file.name) && file.type === 'file' && file.name.slice(0, -4))
+      .filter(name => name && name !== DEFAULT);
+      if (themes) {
+        this.themes = themes;
+        storage.base.set(keyThemeNAMES, themes);
+      }
+    },
     onSave() {
       showMessage({ text: this.$refs.editor.error || this.i18n('msgSavedEditorOptions') });
     },

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

@@ -195,12 +195,6 @@ export default {
 </script>
 
 <style>
-.center-items {
-  align-items: center;
-}
-.stretch-self {
-  align-self: stretch;
-}
 .sync-server-url {
   > input {
     width: 400px;