app.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. <template>
  2. <div
  3. class="page-popup"
  4. @click="activeExtras && toggleExtras(null)"
  5. @contextmenu="activeExtras && (toggleExtras(null), $event.preventDefault())"
  6. @mouseenter.capture="delegateMouseEnter"
  7. @mouseleave.capture="delegateMouseLeave"
  8. @focus.capture="updateMessage"
  9. :data-failure-reason="failureReason">
  10. <div class="flex menu-buttons">
  11. <div class="logo" :class="{disabled:!options.isApplied}">
  12. <img src="/public/images/icon128.png">
  13. </div>
  14. <div
  15. class="flex-1 ext-name"
  16. :class="{disabled:!options.isApplied}"
  17. v-text="name"
  18. />
  19. <span
  20. class="menu-area"
  21. :class="{disabled:!options.isApplied}"
  22. :data-message="options.isApplied ? i18n('menuScriptEnabled') : i18n('menuScriptDisabled')"
  23. :tabIndex="tabIndex"
  24. @click="onToggle">
  25. <icon :name="getSymbolCheck(options.isApplied)"></icon>
  26. </span>
  27. <span
  28. class="menu-area"
  29. :data-message="i18n('menuDashboard')"
  30. :tabIndex="tabIndex"
  31. @click="onManage">
  32. <icon name="cog"></icon>
  33. </span>
  34. <span
  35. class="menu-area"
  36. :data-message="i18n('menuNewScript')"
  37. :tabIndex="tabIndex"
  38. @click="onCreateScript">
  39. <icon name="plus"></icon>
  40. </span>
  41. </div>
  42. <div class="menu" v-if="store.injectable" v-show="store.domain">
  43. <div class="menu-item menu-area menu-find" :tabIndex="tabIndex">
  44. <template v-for="(url, text, i) in findUrls" :key="url">
  45. <a target="_blank" :class="{ ellipsis: !i, 'mr-1': !i, 'ml-1': i }"
  46. :href="url" :data-message="url.split('://')[1]">
  47. <icon name="search" v-if="!i"/>{{text}}
  48. </a>
  49. <template v-if="!i">/</template>
  50. </template>
  51. </div>
  52. </div>
  53. <div class="failure-reason" v-if="failureReasonText">
  54. <tooltip v-if="injectionScopes[0] && !options.isApplied"
  55. :content="i18n('labelAutoReloadCurrentTabDisabled')"
  56. class="reload-hint" align="start" placement="bottom">
  57. <icon name="info"/>
  58. </tooltip>
  59. <span v-text="failureReasonText"/>
  60. <code v-text="store.blacklisted" v-if="store.blacklisted" class="ellipsis inline-block"/>
  61. </div>
  62. <div
  63. v-for="scope in injectionScopes"
  64. class="menu menu-scripts"
  65. :class="{
  66. expand: activeMenu === scope.name,
  67. 'block-scroll': activeExtras,
  68. }"
  69. :data-type="scope.name"
  70. :key="scope.name">
  71. <div
  72. class="menu-item menu-area menu-group"
  73. :tabIndex="tabIndex"
  74. @click="toggleMenu(scope.name)">
  75. <icon name="arrow" class="icon-collapse"></icon>
  76. <div class="flex-auto" v-text="scope.title" :data-totals="scope.totals" />
  77. </div>
  78. <div class="submenu">
  79. <div
  80. v-for="(item, index) in scope.list"
  81. :key="index"
  82. :class="{
  83. disabled: !item.data.config.enabled,
  84. failed: item.data.failed,
  85. removed: item.data.config.removed,
  86. runs: item.data.runs,
  87. 'extras-shown': activeExtras === item,
  88. 'excludes-shown': item.excludesValue,
  89. }"
  90. class="script">
  91. <div
  92. class="menu-item menu-area"
  93. :tabIndex="tabIndex"
  94. :data-message="item.name"
  95. @focus="focusedItem = item"
  96. @keydown.enter.exact.stop="onEditScript(item)"
  97. @keydown.space.exact.stop="onToggleScript(item)"
  98. @click="onToggleScript(item)">
  99. <img class="script-icon" :src="item.data.safeIcon">
  100. <icon :name="getSymbolCheck(item.data.config.enabled)"></icon>
  101. <div class="script-name flex-auto ellipsis" v-text="item.name"
  102. @click.ctrl.exact.stop="onEditScript(item)"
  103. @contextmenu.exact.stop="onEditScript(item)"
  104. @mousedown.middle.exact.stop="onEditScript(item)" />
  105. </div>
  106. <div class="submenu-buttons"
  107. v-show="activeExtras === item || focusedItem === item || focusBug">
  108. <!-- Using a standard tooltip that's shown after a delay to avoid nagging the user -->
  109. <div class="submenu-button" :tabIndex="tabIndex" @click="onEditScript(item)"
  110. :title="i18n('buttonEditClickHint')">
  111. <icon name="code"></icon>
  112. </div>
  113. <div
  114. class="submenu-button"
  115. :tabIndex="tabIndex"
  116. @click.stop="toggleExtras(item, $event)">
  117. <icon name="more"/>
  118. </div>
  119. </div>
  120. <div v-if="item.excludesValue != null" class="excludes-menu flex flex-col">
  121. <textarea v-model="item.excludesValue" spellcheck="false"
  122. :rows="calcRows(item.excludesValue)"/>
  123. <div>
  124. <button v-text="i18n('buttonOK')" @click="onExcludeSave(item)"/>
  125. <button v-text="i18n('buttonCancel')" @click="onExcludeClose(item)"/>
  126. <!-- not using tooltip to preserve line breaks -->
  127. <details>
  128. <summary><icon name="info"/></summary>
  129. <small>
  130. <span v-text="i18n('menuExcludeHint')"/>
  131. <ul class="monospace-font mt-1">
  132. <li>https://www.foo.com/path/*bar*</li>
  133. <li>*://www.foo.com/*</li>
  134. <li>*://*.foo.com/*</li>
  135. </ul>
  136. </small>
  137. </details>
  138. </div>
  139. </div>
  140. <div class="submenu-commands">
  141. <div
  142. class="menu-item menu-area"
  143. v-for="(cap, i) in store.commands[item.data.props.id]"
  144. :key="i"
  145. :tabIndex="tabIndex"
  146. :CMD.prop="{ id: item.data.props.id, cap }"
  147. :data-message="cap"
  148. @mousedown="onCommand"
  149. @mouseup="onCommand"
  150. @keydown.enter="onCommand"
  151. @keydown.space="onCommand">
  152. <icon name="command" />
  153. <div class="flex-auto ellipsis" v-text="cap" />
  154. </div>
  155. </div>
  156. </div>
  157. </div>
  158. </div>
  159. <div class="failure-reason" v-if="store.injectionFailure">
  160. <div v-text="i18n('menuInjectionFailed')"/>
  161. <a v-text="i18n('menuInjectionFailedFix')" href="#"
  162. v-if="store.injectionFailure.fixable"
  163. @click.prevent="onInjectionFailureFix"/>
  164. </div>
  165. <div class="incognito"
  166. v-if="store.currentTab?.incognito"
  167. v-text="i18n('msgIncognitoChanges')"/>
  168. <footer>
  169. <a href="https://violentmonkey.github.io/" :tabIndex="tabIndex" @click.prevent="onVisitWebsite" v-text="i18n('visitWebsite')" />
  170. </footer>
  171. <div class="message" v-show="message">
  172. <div v-text="message"></div>
  173. </div>
  174. <div v-if="activeExtras" class="extras-menu" ref="extrasMenu">
  175. <a v-if="activeExtras.home" tabindex="0" :href="activeExtras.home" v-text="i18n('buttonHome')"
  176. rel="noopener noreferrer" target="_blank"/>
  177. <div v-text="i18n('menuExclude')" tabindex="0" @click="onExclude"/>
  178. <div v-text="activeExtras.data.config.removed ? i18n('buttonRestore') : i18n('buttonRemove')"
  179. tabindex="0"
  180. @click="onRemoveScript(activeExtras)"/>
  181. </div>
  182. </div>
  183. </template>
  184. <script>
  185. import Tooltip from 'vueleton/lib/tooltip';
  186. import { INJECT_AUTO } from '@/common/consts';
  187. import options from '@/common/options';
  188. import { getScriptName, i18n, makePause, sendCmd, sendTabCmd } from '@/common';
  189. import { objectPick } from '@/common/object';
  190. import Icon from '@/common/ui/icon';
  191. import { keyboardService, isInput } from '@/common/keyboard';
  192. import { mutex, store } from '../utils';
  193. const manifest = browser.runtime.getManifest();
  194. const NAME = `${manifest.name} ${manifest.version}`;
  195. const SCRIPT_CLS = '.script';
  196. let mousedownElement;
  197. const optionsData = {
  198. isApplied: options.get('isApplied'),
  199. filtersPopup: options.get('filtersPopup') || {},
  200. };
  201. options.hook((changes) => {
  202. if ('isApplied' in changes) {
  203. optionsData.isApplied = changes.isApplied;
  204. }
  205. if ('filtersPopup' in changes) {
  206. optionsData.filtersPopup = {
  207. ...optionsData.filtersPopup,
  208. ...changes.filtersPopup,
  209. };
  210. }
  211. });
  212. function compareBy(...keys) {
  213. return (a, b) => {
  214. for (const key of keys) {
  215. const ka = key(a);
  216. const kb = key(b);
  217. if (ka < kb) return -1;
  218. if (ka > kb) return 1;
  219. }
  220. return 0;
  221. };
  222. }
  223. export default {
  224. components: {
  225. Icon,
  226. Tooltip,
  227. },
  228. data() {
  229. return {
  230. store,
  231. options: optionsData,
  232. activeMenu: 'scripts',
  233. activeExtras: null,
  234. focusBug: false,
  235. message: null,
  236. focusedItem: null,
  237. name: NAME,
  238. };
  239. },
  240. computed: {
  241. injectionScopes() {
  242. const { sort, enabledFirst, hideDisabled } = this.options.filtersPopup;
  243. const isSorted = sort === 'alpha' || enabledFirst;
  244. const { injectable } = store;
  245. const groupDisabled = hideDisabled === 'group';
  246. return [
  247. injectable && ['scripts', i18n('menuMatchedScripts'), groupDisabled || null],
  248. injectable && groupDisabled && ['disabled', i18n('menuMatchedDisabledScripts'), false],
  249. ['frameScripts', i18n('menuMatchedFrameScripts')],
  250. ]
  251. .filter(Boolean)
  252. .map(([name, title, groupByEnabled]) => {
  253. let list = store[name] || store.scripts;
  254. if (groupByEnabled != null) {
  255. list = list.filter(script => !script.config.enabled === !groupByEnabled);
  256. }
  257. const numTotal = list.length;
  258. const numEnabled = groupByEnabled == null
  259. ? list.reduce((num, script) => num + script.config.enabled, 0)
  260. : numTotal;
  261. if (hideDisabled === 'hide' || hideDisabled === true) {
  262. list = list.filter(script => script.config.enabled);
  263. }
  264. list = list.map((script, i) => {
  265. const { config, custom, meta } = script;
  266. const scriptName = getScriptName(script);
  267. return {
  268. id: `${name}/${script.props.id}`,
  269. name: scriptName,
  270. data: script,
  271. home: custom.homepageURL || meta.homepageURL || meta.homepage,
  272. key: isSorted && `${
  273. enabledFirst && +!config.enabled
  274. }${
  275. sort === 'alpha' ? scriptName.toLowerCase() : `${1e6 + i}`.slice(1)
  276. }`,
  277. excludesValue: null,
  278. };
  279. });
  280. if (isSorted) {
  281. list.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key));
  282. }
  283. return numTotal && {
  284. name,
  285. title,
  286. list,
  287. totals: numEnabled < numTotal
  288. ? `${numEnabled} / ${numTotal}`
  289. : `${numTotal}`,
  290. };
  291. }).filter(Boolean);
  292. },
  293. failureReason() {
  294. return [
  295. !store.injectable && 'noninjectable',
  296. store.blacklisted && 'blacklisted',
  297. // undefined means the data isn't ready yet
  298. optionsData.isApplied === false && 'scripts-disabled',
  299. ].filter(Boolean).join(' ');
  300. },
  301. failureReasonText() {
  302. return (
  303. !store.injectable && i18n('failureReasonNoninjectable')
  304. || store.blacklisted && i18n('failureReasonBlacklisted')
  305. || optionsData.isApplied === false && i18n('menuScriptDisabled')
  306. || ''
  307. );
  308. },
  309. findUrls() {
  310. const query = encodeURIComponent(store.domain);
  311. return {
  312. [`${i18n('menuFindScripts')} (GF)`]: `https://greasyfork.org/scripts/by-site/${query}`,
  313. OUJS: `https://openuserjs.org/?q=${query}`,
  314. };
  315. },
  316. tabIndex() {
  317. return this.activeExtras ? -1 : 0;
  318. },
  319. },
  320. methods: {
  321. toggleMenu(name) {
  322. this.activeMenu = this.activeMenu === name ? null : name;
  323. },
  324. toggleExtras(item, evt) {
  325. this.activeExtras = this.activeExtras === item ? null : item;
  326. keyboardService.setContext('activeExtras', this.activeExtras);
  327. if (this.activeExtras) {
  328. item.el = evt.target.closest(SCRIPT_CLS);
  329. this.$nextTick(() => {
  330. const { extrasMenu } = this.$refs;
  331. extrasMenu.style.top = `${
  332. Math.min(window.innerHeight - extrasMenu.getBoundingClientRect().height,
  333. (evt.currentTarget || evt.target).getBoundingClientRect().top + 16)
  334. }px`;
  335. });
  336. }
  337. },
  338. getSymbolCheck(bool) {
  339. return `toggle-${bool ? 'on' : 'off'}`;
  340. },
  341. onToggle() {
  342. options.set('isApplied', optionsData.isApplied = !optionsData.isApplied);
  343. this.checkReload();
  344. },
  345. onManage() {
  346. browser.runtime.openOptionsPage();
  347. window.close();
  348. },
  349. onVisitWebsite() {
  350. sendCmd('TabOpen', {
  351. url: 'https://violentmonkey.github.io/',
  352. });
  353. window.close();
  354. },
  355. onEditScript(item) {
  356. sendCmd('OpenEditor', item.data.props.id);
  357. window.close();
  358. },
  359. onCommand(evt) {
  360. const { type, currentTarget: el } = evt;
  361. if (type === 'mousedown') {
  362. mousedownElement = el;
  363. evt.preventDefault();
  364. } else if (type === 'keydown' || mousedownElement === el) {
  365. sendTabCmd(store.currentTab.id, 'Command', {
  366. ...el.CMD,
  367. evt: objectPick(evt, ['type', 'button', 'shiftKey', 'altKey', 'ctrlKey', 'metaKey',
  368. 'key', 'keyCode', 'code']),
  369. });
  370. window.close();
  371. }
  372. },
  373. onToggleScript(item) {
  374. const { data } = item;
  375. const enabled = !data.config.enabled;
  376. sendCmd('UpdateScriptInfo', {
  377. id: data.props.id,
  378. config: { enabled },
  379. })
  380. .then(() => {
  381. data.config.enabled = enabled;
  382. this.checkReload();
  383. });
  384. },
  385. checkReload() {
  386. if (options.get('autoReload')) {
  387. browser.tabs.reload(store.currentTab.id);
  388. store.scriptIds.length = 0;
  389. store.scripts.length = 0;
  390. store.frameScripts.length = 0;
  391. mutex.init();
  392. }
  393. },
  394. async onCreateScript() {
  395. sendCmd('OpenEditor');
  396. window.close();
  397. },
  398. async onInjectionFailureFix() {
  399. // TODO: promisify options.set, resolve on storage write, await it instead of makePause
  400. options.set('defaultInjectInto', INJECT_AUTO);
  401. await makePause(100);
  402. await browser.tabs.reload();
  403. window.close();
  404. },
  405. onRemoveScript({ data: { config, props: { id } } }) {
  406. const removed = +!config.removed;
  407. config.removed = removed;
  408. sendCmd('MarkRemoved', { id, removed });
  409. },
  410. onExclude() {
  411. const item = this.activeExtras;
  412. item.excludesValue = [
  413. ...item.data.custom.excludeMatch || [],
  414. `${item.data.pageUrl.split('#')[0]}*`,
  415. ].join('\n');
  416. this.$nextTick(() => {
  417. // not using $refs because multiple items may show textareas
  418. item.el.querySelector('textarea').focus();
  419. });
  420. },
  421. onExcludeClose(item) {
  422. item.excludesValue = null;
  423. this.focus(item);
  424. },
  425. async onExcludeSave(item) {
  426. await sendCmd('UpdateScriptInfo', {
  427. id: item.data.props.id,
  428. custom: {
  429. excludeMatch: item.excludesValue.split('\n').map(line => line.trim()).filter(Boolean),
  430. },
  431. });
  432. this.onExcludeClose(item);
  433. this.checkReload();
  434. },
  435. navigate(dir) {
  436. const { activeElement } = document;
  437. const items = Array.from(this.$el.querySelectorAll('[tabindex="0"]'))
  438. .map(el => ({
  439. el,
  440. rect: el.getBoundingClientRect(),
  441. }))
  442. .filter(({ rect }) => rect.width && rect.height);
  443. items.sort(compareBy(item => item.rect.top, item => item.rect.left));
  444. let index = items.findIndex(({ el }) => el === activeElement);
  445. const findItemIndex = (step, test) => {
  446. for (let i = index + step; i >= 0 && i < items.length; i += step) {
  447. if (test(items[index], items[i])) return i;
  448. }
  449. };
  450. if (index < 0) {
  451. index = 0;
  452. } else if (dir === 'u' || dir === 'd') {
  453. const step = dir === 'u' ? -1 : 1;
  454. index = findItemIndex(step, (a, b) => (a.rect.top - b.rect.top) * step < 0);
  455. if (dir === 'u') {
  456. while (index > 0 && items[index - 1].rect.top === items[index].rect.top) index -= 1;
  457. }
  458. } else {
  459. const step = dir === 'l' ? -1 : 1;
  460. index = findItemIndex(step, (a, b) => (a.rect.left - b.rect.left) * step < 0);
  461. }
  462. items[index]?.el.focus();
  463. },
  464. focus(item) {
  465. item?.el?.querySelector('.menu-area')?.focus();
  466. },
  467. delegateMouseEnter(e) {
  468. const { target } = e;
  469. if (target.tabIndex >= 0) target.focus();
  470. },
  471. delegateMouseLeave(e) {
  472. const { target } = e;
  473. if (target === document.activeElement && !isInput(target)) target.blur();
  474. },
  475. updateMessage() {
  476. this.message = document.activeElement?.dataset.message || '';
  477. },
  478. },
  479. mounted() {
  480. keyboardService.enable();
  481. this.disposeList = [
  482. keyboardService.register('escape', () => {
  483. const item = this.activeExtras;
  484. if (item) {
  485. this.toggleExtras(null);
  486. this.focus(item);
  487. } else if (document.activeElement?.value) {
  488. document.activeElement.blur();
  489. } else {
  490. window.close();
  491. }
  492. }),
  493. ...['up', 'down', 'left', 'right'].map(key => (
  494. keyboardService.register(key,
  495. this.navigate.bind(this, key[0]),
  496. { condition: '!inputFocus' })
  497. )),
  498. keyboardService.register('e', () => {
  499. this.onEditScript(this.focusedItem);
  500. }, {
  501. condition: '!inputFocus',
  502. }),
  503. ];
  504. },
  505. activated() {
  506. // issue #1520: Firefox + Wayland doesn't autofocus the popup so CSS hover doesn't work
  507. this.focusBug = !document.hasFocus();
  508. },
  509. beforeUnmount() {
  510. keyboardService.disable();
  511. this.disposeList?.forEach(dispose => { dispose(); });
  512. },
  513. };
  514. </script>
  515. <style src="../style.css"></style>