index.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. <template>
  2. <div class="edit frame flex flex-col abs-full">
  3. <div class="edit-header flex mr-1c">
  4. <nav>
  5. <div
  6. v-for="(label, navKey) in navItems" :key="navKey"
  7. class="edit-nav-item" :class="{active: nav === navKey}"
  8. v-text="label"
  9. @click="nav = navKey"
  10. />
  11. </nav>
  12. <div class="edit-name text-center ellipsis flex-1">
  13. <span class="subtle" v-if="script?.config?.removed" v-text="i18n('headerRecycleBin') + ' / '"></span>
  14. {{scriptName}}
  15. </div>
  16. <div class="edit-hint text-right ellipsis">
  17. <a href="https://violentmonkey.github.io/posts/how-to-edit-scripts-with-your-favorite-editor/"
  18. target="_blank"
  19. rel="noopener noreferrer"
  20. v-text="i18n('editHowToHint')"/>
  21. </div>
  22. <div class="edit-buttons">
  23. <button v-text="i18n('buttonSave')" @click="save" :disabled="!canSave"
  24. :class="{'has-error': errors}" :title="errors"/>
  25. <button v-text="i18n('buttonSaveClose')" @click="saveClose" :disabled="!canSave"/>
  26. <button v-text="i18n('buttonClose')" @click="close"/>
  27. </div>
  28. </div>
  29. <vm-code
  30. class="flex-auto"
  31. :value="code"
  32. :readOnly="readOnly"
  33. ref="code"
  34. v-show="nav === 'code'"
  35. :active="nav === 'code'"
  36. :commands="commands"
  37. @code-dirty="codeDirty = $event"
  38. />
  39. <vm-settings
  40. class="edit-body"
  41. v-show="nav === 'settings'"
  42. :readOnly="readOnly"
  43. :active="nav === 'settings'"
  44. :settings="settings"
  45. :value="script"
  46. />
  47. <vm-values
  48. class="edit-body"
  49. v-show="nav === 'values'"
  50. :readOnly="readOnly"
  51. :active="nav === 'values'"
  52. :script="script"
  53. />
  54. <vm-externals
  55. class="flex-auto"
  56. v-if="nav === 'externals'"
  57. :value="script"
  58. />
  59. <vm-help
  60. class="edit-body"
  61. v-show="nav === 'help'"
  62. :hotkeys="hotkeys"
  63. />
  64. <div v-if="errors" class="errors my-1c">
  65. <p v-for="e in errors" :key="e" v-text="e" class="text-red"/>
  66. <p class="my-1">
  67. <a :href="urlMatching" target="_blank" rel="noopener noreferrer" v-text="urlMatching"/>
  68. </p>
  69. </div>
  70. </div>
  71. </template>
  72. <script>
  73. import {
  74. browserWindows,
  75. debounce, formatByteLength, getScriptName, i18n, isEmpty,
  76. sendCmdDirectly, trueJoin,
  77. } from '@/common';
  78. import { deepCopy, deepEqual, objectPick } from '@/common/object';
  79. import { showConfirmation, showMessage } from '@/common/ui';
  80. import { keyboardService } from '@/common/keyboard';
  81. import VmCode from '@/common/ui/code';
  82. import VmExternals from '@/common/ui/externals';
  83. import options from '@/common/options';
  84. import { getUnloadSentry } from '@/common/router';
  85. import { store } from '../../utils';
  86. import VmSettings from './settings';
  87. import VmValues from './values';
  88. import VmHelp from './help';
  89. const CUSTOM_PROPS = {
  90. name: '',
  91. [RUN_AT]: '',
  92. homepageURL: '',
  93. updateURL: '',
  94. downloadURL: '',
  95. origInclude: true,
  96. origExclude: true,
  97. origMatch: true,
  98. origExcludeMatch: true,
  99. };
  100. const CUSTOM_LISTS = [
  101. 'include',
  102. 'match',
  103. 'exclude',
  104. 'excludeMatch',
  105. ];
  106. const fromList = list => (
  107. list
  108. // Adding a new row so the user can click it and type, just like in an empty textarea.
  109. ? `${list.join('\n')}${list.length ? '\n' : ''}`
  110. : ''
  111. );
  112. const toList = text => (
  113. text.split('\n')
  114. .map(line => line.trim())
  115. .filter(Boolean)
  116. );
  117. let savedSettings;
  118. let shouldSavePositionOnSave;
  119. /** @param {chrome.windows.Window} [wnd] */
  120. const savePosition = async wnd => {
  121. if (options.get('editorWindow')) {
  122. if (!wnd) wnd = await browserWindows?.getCurrent() || {};
  123. /* chrome.windows API can't set both the state and coords, so we have to choose:
  124. * either we save the min/max state and lose the coords on restore,
  125. * or we lose the min/max state and save the normal coords.
  126. * Let's assume those who use a window prefer it at a certain position most of the time,
  127. * and occasionally minimize/maximize it, but wouldn't want to save the state. */
  128. if (wnd.state === 'normal') {
  129. options.set('editorWindowPos', objectPick(wnd, ['left', 'top', 'width', 'height']));
  130. }
  131. }
  132. };
  133. /** @param {chrome.windows.Window} _ */
  134. const setupSavePosition = ({ id: curWndId, tabs }) => {
  135. if (tabs.length === 1) {
  136. const { onBoundsChanged } = chrome.windows;
  137. if (onBoundsChanged) {
  138. // triggered on moving/resizing, Chrome 86+
  139. onBoundsChanged.addListener(wnd => {
  140. if (wnd.id === curWndId) savePosition(wnd);
  141. });
  142. } else {
  143. // triggered on resizing only
  144. window.addEventListener('resize', debounce(savePosition, 100));
  145. shouldSavePositionOnSave = true;
  146. }
  147. }
  148. };
  149. let K_SAVE; // deduced from the current CodeMirror keymap
  150. const K_PREV_PANEL = 'Alt-PageUp';
  151. const K_NEXT_PANEL = 'Alt-PageDown';
  152. const compareString = (a, b) => (a < b ? -1 : a > b);
  153. export default {
  154. props: ['initial', 'initialCode', 'readOnly'],
  155. components: {
  156. VmCode,
  157. VmSettings,
  158. VmValues,
  159. VmExternals,
  160. VmHelp,
  161. },
  162. data() {
  163. return {
  164. nav: 'code',
  165. canSave: false,
  166. script: null,
  167. code: '',
  168. codeDirty: false,
  169. settings: {},
  170. commands: {
  171. save: this.save,
  172. close: this.close,
  173. showHelp: () => {
  174. this.nav = 'help';
  175. },
  176. },
  177. hotkeys: null,
  178. errors: null,
  179. urlMatching: 'https://violentmonkey.github.io/api/matching/',
  180. };
  181. },
  182. computed: {
  183. navItems() {
  184. const { meta, props } = this.script || {};
  185. const req = meta?.require.length && '@require';
  186. const res = !isEmpty(meta?.resources) && '@resource';
  187. const size = store.storageSize;
  188. return {
  189. code: i18n('editNavCode'),
  190. settings: i18n('editNavSettings'),
  191. ...props?.id && {
  192. values: i18n('editNavValues') + (size ? ` (${formatByteLength(size)})` : ''),
  193. },
  194. ...(req || res) && { externals: [req, res]::trueJoin('/') },
  195. help: '?',
  196. };
  197. },
  198. scriptName() {
  199. const { script } = this;
  200. const scriptName = script?.meta && getScriptName(script);
  201. store.title = scriptName;
  202. return scriptName;
  203. },
  204. },
  205. watch: {
  206. nav(val) {
  207. keyboardService.setContext('tabCode', val === 'code');
  208. if (val === 'code') {
  209. this.$nextTick(() => {
  210. this.$refs.code.cm.focus();
  211. });
  212. }
  213. },
  214. canSave(val) {
  215. this.toggleUnloadSentry(val);
  216. keyboardService.setContext('canSave', val);
  217. },
  218. // usually errors for resources
  219. 'initial.error'(error) {
  220. if (error) {
  221. showMessage({ text: `${this.initial.message}\n\n${error}` });
  222. }
  223. },
  224. },
  225. created() {
  226. this.script = this.initial;
  227. this.toggleUnloadSentry = getUnloadSentry(null, () => {
  228. this.$refs.code.cm.focus();
  229. });
  230. if (options.get('editorWindow') && global.history.length === 1) {
  231. browser.windows?.getCurrent({ populate: true }).then(setupSavePosition);
  232. }
  233. },
  234. async mounted() {
  235. document.body.classList.add('edit-open');
  236. store.storageSize = 0;
  237. this.nav = 'code';
  238. const { custom, config } = this.script;
  239. const { noframes } = custom;
  240. this.settings = {
  241. config: {
  242. notifyUpdates: `${config.notifyUpdates ?? ''}`,
  243. // Needs to match Vue model type so deepEqual can work properly
  244. shouldUpdate: Boolean(config.shouldUpdate),
  245. },
  246. custom: {
  247. // Adding placeholders for any missing values so deepEqual can work properly
  248. ...CUSTOM_PROPS,
  249. ...objectPick(custom, Object.keys(CUSTOM_PROPS)),
  250. ...objectPick(custom, CUSTOM_LISTS, fromList),
  251. [RUN_AT]: custom[RUN_AT] || '',
  252. noframes: noframes == null ? '' : +noframes, // it was boolean in old VM
  253. },
  254. };
  255. savedSettings = deepCopy(this.settings);
  256. this.$watch('codeDirty', this.onChange);
  257. this.$watch('settings', this.onChange, { deep: true });
  258. // hotkeys
  259. {
  260. const navLabels = Object.values(this.navItems);
  261. const hotkeys = [
  262. [K_PREV_PANEL, ` ${navLabels.join(' < ')}`],
  263. [K_NEXT_PANEL, ` ${navLabels.join(' > ')}`],
  264. ...Object.entries(this.$refs.code.expandKeyMap())
  265. .sort((a, b) => compareString(a[1], b[1]) || compareString(a[0], b[0])),
  266. ];
  267. K_SAVE = hotkeys.find(([, cmd]) => cmd === 'save')?.[0];
  268. if (!K_SAVE) {
  269. K_SAVE = 'Ctrl-S';
  270. hotkeys.unshift([K_SAVE, 'save']);
  271. }
  272. this.hotkeys = hotkeys;
  273. }
  274. this.disposeList = [
  275. keyboardService.register('a-pageup', this.switchPrevPanel),
  276. keyboardService.register('a-pagedown', this.switchNextPanel),
  277. keyboardService.register(K_SAVE.replace(/(?:Ctrl|Cmd)-/i, 'ctrlcmd-'), this.save),
  278. keyboardService.register('escape', () => { this.nav = 'code'; }, {
  279. condition: '!tabCode',
  280. }),
  281. ];
  282. this.code = this.initialCode;
  283. },
  284. methods: {
  285. async save() {
  286. if (!this.canSave) return;
  287. if (shouldSavePositionOnSave) savePosition();
  288. const { settings } = this;
  289. const { config, custom } = settings;
  290. const { notifyUpdates } = config;
  291. const { noframes } = custom;
  292. try {
  293. const codeComponent = this.$refs.code;
  294. const id = this.script?.props?.id;
  295. const res = await sendCmdDirectly('ParseScript', {
  296. id,
  297. code: codeComponent.getRealContent(),
  298. config: {
  299. ...config,
  300. notifyUpdates: notifyUpdates ? +notifyUpdates : null,
  301. },
  302. custom: {
  303. ...objectPick(custom, Object.keys(CUSTOM_PROPS)),
  304. ...objectPick(custom, CUSTOM_LISTS, toList),
  305. noframes: noframes ? +noframes : null,
  306. },
  307. // User created scripts MUST be marked `isNew` so that
  308. // the backend is able to check namespace conflicts,
  309. // otherwise the script with same namespace will be overridden
  310. isNew: !id,
  311. message: '',
  312. });
  313. const newId = res?.where?.id;
  314. savedSettings = deepCopy(settings);
  315. codeComponent.cm.markClean();
  316. this.codeDirty = false; // triggers onChange which sets canSave
  317. this.canSave = false; // ...and set it explicitly in case codeDirty was false
  318. this.errors = res.errors;
  319. if (newId) {
  320. this.script = res.update;
  321. if (!id) history.replaceState(null, this.scriptName, `${ROUTE_SCRIPTS}/${newId}`);
  322. }
  323. } catch (err) {
  324. showConfirmation(`${err.message || err}`, {
  325. cancel: false,
  326. });
  327. }
  328. },
  329. close(cm) {
  330. if (cm && this.nav !== 'code') {
  331. this.nav = 'code';
  332. } else {
  333. this.$emit('close');
  334. // FF doesn't emit `blur` when CodeMirror's textarea is removed
  335. if (IS_FIREFOX) document.activeElement?.blur();
  336. }
  337. },
  338. saveClose() {
  339. this.save().then(this.close);
  340. },
  341. switchPanel(step) {
  342. const keys = Object.keys(this.navItems);
  343. this.nav = keys[(keys.indexOf(this.nav) + step + keys.length) % keys.length];
  344. },
  345. switchPrevPanel() {
  346. this.switchPanel(-1);
  347. },
  348. switchNextPanel() {
  349. this.switchPanel(1);
  350. },
  351. onChange() {
  352. this.canSave = this.codeDirty || !deepEqual(this.settings, savedSettings);
  353. },
  354. },
  355. beforeUnmount() {
  356. document.body.classList.remove('edit-open');
  357. store.title = null;
  358. this.toggleUnloadSentry(false);
  359. this.disposeList?.forEach(dispose => {
  360. dispose();
  361. });
  362. },
  363. };
  364. </script>
  365. <style>
  366. .edit {
  367. z-index: 2000;
  368. &-header {
  369. position: sticky;
  370. top: 0;
  371. z-index: 1;
  372. align-items: center;
  373. justify-content: space-between;
  374. border-bottom: 1px solid var(--fill-3);
  375. background: inherit;
  376. }
  377. &-name {
  378. font-weight: bold;
  379. }
  380. &-body {
  381. padding: .5rem 1rem;
  382. // overflow: auto;
  383. background: var(--bg);
  384. }
  385. &-nav-item {
  386. display: inline-block;
  387. padding: 8px 16px;
  388. cursor: pointer;
  389. &.active {
  390. background: var(--bg);
  391. box-shadow: 0 -1px 1px var(--fill-7);
  392. }
  393. &:not(.active):hover {
  394. background: var(--fill-0-5);
  395. box-shadow: 0 -1px 1px var(--fill-4);
  396. }
  397. }
  398. .edit-externals {
  399. --border: 0;
  400. .select {
  401. padding-top: 0.5em;
  402. @media (max-width: 1599px) {
  403. resize: vertical;
  404. &[style*=height] {
  405. max-height: 80%;
  406. }
  407. &[style*=width] {
  408. width: auto !important;
  409. }
  410. }
  411. }
  412. @media (min-width: 1600px) {
  413. flex-direction: row;
  414. .select {
  415. resize: horizontal;
  416. min-width: 15em;
  417. width: 30%;
  418. max-height: none;
  419. border-bottom: none;
  420. &[style*=height] {
  421. height: auto !important;
  422. }
  423. &[style*=width] {
  424. max-width: 80%;
  425. }
  426. }
  427. }
  428. }
  429. .errors {
  430. border-top: 2px solid red;
  431. padding: .5em 1em;
  432. }
  433. }
  434. @media (max-width: 767px) {
  435. .edit-hint {
  436. display: none;
  437. }
  438. .edit {
  439. // fixed/absolute doesn't work well with scroll in Firefox Android
  440. position: static;
  441. // larger than 100vh to force overflow so that the toolbar can be hidden in Firefox Android
  442. min-height: calc(100vh + 1px);
  443. }
  444. }
  445. @media (max-width: 500px) {
  446. .edit-name {
  447. display: none;
  448. }
  449. }
  450. </style>