script-item.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. <template>
  2. <div class="script" :class="{ disabled: !script.config.enabled, removed: script.config.removed }" :draggable="draggable" @dragstart.prevent="onDragStart">
  3. <img class="script-icon" :src="safeIcon">
  4. <div class="script-info flex">
  5. <div class="script-name ellipsis" v-text="script._cache.name"></div>
  6. <div class="flex-auto"></div>
  7. <tooltip :title="i18n('labelAuthor') + script.meta.author" class="script-author" v-if="author" align="end">
  8. <icon name="author"></icon>
  9. <a class="ellipsis" :href="`mailto:${author.email}`" v-if="author.email" v-text="author.name"></a>
  10. <span class="ellipsis" v-else v-text="author.name"></span>
  11. </tooltip>
  12. <tooltip :title="lastUpdated.title" align="end">
  13. <span v-text="script.meta.version ? `v${script.meta.version}` : ''"></span>
  14. <span class="secondary" v-text="lastUpdated.show"></span>
  15. </tooltip>
  16. <div v-if="script.config.removed" v-text="i18n('labelRemoved')"></div>
  17. <div v-if="script.config.removed">
  18. <tooltip :title="i18n('buttonUndo')" placement="left">
  19. <span class="btn-ghost" @click="onRemove(0)">
  20. <icon name="undo"></icon>
  21. </span>
  22. </tooltip>
  23. </div>
  24. </div>
  25. <div class="script-buttons flex">
  26. <tooltip :title="i18n('buttonEdit')" align="start">
  27. <span class="btn-ghost" @click="onEdit">
  28. <icon name="code"></icon>
  29. </span>
  30. </tooltip>
  31. <tooltip :title="labelEnable" align="start">
  32. <span class="btn-ghost" @click="onEnable">
  33. <icon :name="`toggle-${script.config.enabled ? 'on' : 'off'}`"></icon>
  34. </span>
  35. </tooltip>
  36. <tooltip :disabled="!canUpdate || script.checking" :title="i18n('buttonUpdate')" align="start">
  37. <span class="btn-ghost" @click="onUpdate">
  38. <icon name="refresh"></icon>
  39. </span>
  40. </tooltip>
  41. <span class="sep"></span>
  42. <tooltip :disabled="!homepageURL" :title="i18n('buttonHome')" align="start">
  43. <a class="btn-ghost" target="_blank" :href="homepageURL">
  44. <icon name="home"></icon>
  45. </a>
  46. </tooltip>
  47. <tooltip :disabled="!description" :title="description" align="start">
  48. <span class="btn-ghost">
  49. <icon name="info"></icon>
  50. </span>
  51. </tooltip>
  52. <tooltip :disabled="!script.meta.supportURL" :title="i18n('buttonSupport')" align="start">
  53. <a class="btn-ghost" target="_blank" :href="script.meta.supportURL">
  54. <icon name="question"></icon>
  55. </a>
  56. </tooltip>
  57. <div class="flex-auto" v-text="script.message"></div>
  58. <tooltip :title="i18n('buttonRemove')" align="end">
  59. <span class="btn-ghost" @click="onRemove(1)">
  60. <icon name="trash"></icon>
  61. </span>
  62. </tooltip>
  63. </div>
  64. </div>
  65. </template>
  66. <script>
  67. import { sendMessage, getLocaleString } from 'src/common';
  68. import Icon from 'src/common/ui/icon';
  69. import Tooltip from 'src/common/ui/tooltip';
  70. import { store } from '../utils';
  71. const DEFAULT_ICON = '/public/images/icon48.png';
  72. const PADDING = 10;
  73. const SCROLL_GAP = 10;
  74. const images = {};
  75. function loadImage(url) {
  76. if (!url) return Promise.reject();
  77. let promise = images[url];
  78. if (!promise) {
  79. const cache = store.cache[url];
  80. promise = cache
  81. ? Promise.resolve(cache)
  82. : new Promise((resolve, reject) => {
  83. const img = new Image();
  84. img.onload = () => resolve(url);
  85. img.onerror = () => reject(url);
  86. img.src = url;
  87. });
  88. images[url] = promise;
  89. }
  90. return promise;
  91. }
  92. export default {
  93. props: ['script', 'draggable'],
  94. components: {
  95. Icon,
  96. Tooltip,
  97. },
  98. data() {
  99. return {
  100. safeIcon: DEFAULT_ICON,
  101. };
  102. },
  103. computed: {
  104. canUpdate() {
  105. const { script } = this;
  106. return script.config.shouldUpdate && (
  107. script.custom.updateURL ||
  108. script.meta.updateURL ||
  109. script.custom.downloadURL ||
  110. script.meta.downloadURL ||
  111. script.custom.lastInstallURL
  112. );
  113. },
  114. homepageURL() {
  115. const { script } = this;
  116. return script.custom.homepageURL || script.meta.homepageURL || script.meta.homepage;
  117. },
  118. author() {
  119. const text = this.script.meta.author;
  120. if (!text) return;
  121. const matches = text.match(/^(.*?)\s<(\S*?@\S*?)>$/);
  122. return {
  123. email: matches && matches[2],
  124. name: matches ? matches[1] : text,
  125. };
  126. },
  127. labelEnable() {
  128. return this.script.config.enabled ? this.i18n('buttonDisable') : this.i18n('buttonEnable');
  129. },
  130. description() {
  131. return this.script.custom.description || getLocaleString(this.script.meta, 'description');
  132. },
  133. lastUpdated() {
  134. const { props } = this.script;
  135. // XXX use `lastModified` as a fallback for scripts without `lastUpdated`
  136. const lastUpdated = props.lastUpdated || props.lastModified;
  137. const ret = {};
  138. if (lastUpdated) {
  139. let delta = (Date.now() - lastUpdated) / 1000 / 60;
  140. const units = [
  141. ['min', 60],
  142. ['h', 24],
  143. ['d', 1000, 365],
  144. ['y'],
  145. ];
  146. const unitInfo = units.find(item => {
  147. const max = item[1];
  148. if (!max || delta < max) return true;
  149. const step = item[2] || max;
  150. delta /= step;
  151. return false;
  152. });
  153. const date = new Date(lastUpdated);
  154. ret.title = this.i18n('labelLastUpdatedAt', date.toLocaleString());
  155. ret.show = `${delta | 0}${unitInfo[0]}`;
  156. }
  157. return ret;
  158. },
  159. },
  160. mounted() {
  161. const { icon } = this.script.meta;
  162. if (icon && icon !== this.safeIcon) {
  163. loadImage(icon)
  164. .then(url => {
  165. this.safeIcon = url;
  166. }, () => {
  167. this.safeIcon = DEFAULT_ICON;
  168. });
  169. }
  170. },
  171. methods: {
  172. onEdit() {
  173. this.$emit('edit', this.script.props.id);
  174. },
  175. onRemove(remove) {
  176. sendMessage({
  177. cmd: 'UpdateScriptInfo',
  178. data: {
  179. id: this.script.props.id,
  180. config: {
  181. removed: remove ? 1 : 0,
  182. },
  183. },
  184. });
  185. },
  186. onEnable() {
  187. sendMessage({
  188. cmd: 'UpdateScriptInfo',
  189. data: {
  190. id: this.script.props.id,
  191. config: {
  192. enabled: this.script.config.enabled ? 0 : 1,
  193. },
  194. },
  195. });
  196. },
  197. onUpdate() {
  198. sendMessage({
  199. cmd: 'CheckUpdate',
  200. data: this.script.props.id,
  201. });
  202. },
  203. onDragStart(e) {
  204. const el = e.currentTarget;
  205. const parent = el.parentNode;
  206. const rect = el.getBoundingClientRect();
  207. const next = el.nextElementSibling;
  208. const dragging = {
  209. el,
  210. offset: {
  211. x: e.clientX - rect.left,
  212. y: e.clientY - rect.top,
  213. },
  214. delta: (next ? next.getBoundingClientRect().top : parent.offsetHeight) - rect.top,
  215. index: [].indexOf.call(parent.children, el),
  216. elements: [].filter.call(parent.children, child => child !== el),
  217. dragged: el.cloneNode(true),
  218. };
  219. this.dragging = dragging;
  220. dragging.lastIndex = dragging.index;
  221. const { dragged } = dragging;
  222. dragged.classList.add('dragging');
  223. dragged.style.left = `${rect.left}px`;
  224. dragged.style.top = `${rect.top}px`;
  225. dragged.style.width = `${rect.width}px`;
  226. parent.appendChild(dragged);
  227. el.classList.add('dragging-placeholder');
  228. document.addEventListener('mousemove', this.onDragMouseMove, false);
  229. document.addEventListener('mouseup', this.onDragMouseUp, false);
  230. },
  231. onDragMouseMove(e) {
  232. const { dragging } = this;
  233. const {
  234. el, dragged, offset, elements, lastIndex,
  235. } = dragging;
  236. dragged.style.left = `${e.clientX - offset.x}px`;
  237. dragged.style.top = `${e.clientY - offset.y}px`;
  238. let hoveredIndex = elements.findIndex(item => {
  239. if (!item || item.classList.contains('dragging-moving')) return false;
  240. const rect = item.getBoundingClientRect();
  241. return (
  242. e.clientX >= rect.left + PADDING
  243. && e.clientX <= rect.left + rect.width - PADDING
  244. && e.clientY >= rect.top + PADDING
  245. && e.clientY <= rect.top + rect.height - PADDING
  246. );
  247. });
  248. if (hoveredIndex >= 0) {
  249. const hoveredEl = elements[hoveredIndex];
  250. const isDown = hoveredIndex >= lastIndex;
  251. let { delta } = dragging;
  252. if (isDown) {
  253. hoveredIndex += 1;
  254. hoveredEl.parentNode.insertBefore(el, hoveredEl.nextElementSibling);
  255. } else {
  256. delta = -delta;
  257. hoveredEl.parentNode.insertBefore(el, hoveredEl);
  258. }
  259. dragging.lastIndex = hoveredIndex;
  260. this.onDragAnimate(dragging.elements.slice(
  261. isDown ? lastIndex : hoveredIndex,
  262. isDown ? hoveredIndex : lastIndex,
  263. ), delta);
  264. }
  265. this.onDragScrollCheck(e.clientY);
  266. },
  267. onDragMouseUp() {
  268. document.removeEventListener('mousemove', this.onDragMouseMove, false);
  269. document.removeEventListener('mouseup', this.onDragMouseUp, false);
  270. const { dragging } = this;
  271. this.dragging = null;
  272. dragging.dragged.remove();
  273. dragging.el.classList.remove('dragging-placeholder');
  274. this.$emit('move', {
  275. from: dragging.index,
  276. to: dragging.lastIndex,
  277. });
  278. },
  279. onDragAnimate(elements, delta) {
  280. elements.forEach(el => {
  281. if (!el) return;
  282. el.classList.add('dragging-moving');
  283. el.style.transition = 'none';
  284. el.style.transform = `translateY(${delta}px)`;
  285. el.addEventListener('transitionend', endAnimation, false);
  286. setTimeout(() => {
  287. el.style.transition = '';
  288. el.style.transform = '';
  289. });
  290. });
  291. function endAnimation(e) {
  292. e.target.classList.remove('dragging-moving');
  293. e.target.removeEventListener('transitionend', endAnimation, false);
  294. }
  295. },
  296. onDragScrollCheck(y) {
  297. const { dragging } = this;
  298. let scrollSpeed = 0;
  299. const offset = dragging.el.parentNode.getBoundingClientRect();
  300. let delta = (y - (offset.bottom - SCROLL_GAP)) / SCROLL_GAP;
  301. if (delta > 0) {
  302. // scroll down
  303. scrollSpeed = 1 + Math.min((delta * 5) | 0, 10);
  304. } else {
  305. // scroll up
  306. delta = (offset.top + SCROLL_GAP - y) / SCROLL_GAP;
  307. if (delta > 0) scrollSpeed = -1 - Math.min((delta * 5) | 0, 10);
  308. }
  309. dragging.scrollSpeed = scrollSpeed;
  310. if (scrollSpeed) this.onDragScroll();
  311. },
  312. onDragScroll() {
  313. const scroll = () => {
  314. const { dragging } = this;
  315. if (!dragging) return;
  316. if (dragging.scrollSpeed) {
  317. dragging.el.parentNode.scrollTop += dragging.scrollSpeed;
  318. setTimeout(scroll, 32);
  319. } else dragging.scrolling = false;
  320. };
  321. if (this.dragging && !this.dragging.scrolling) {
  322. this.dragging.scrolling = true;
  323. scroll();
  324. }
  325. },
  326. },
  327. };
  328. </script>
  329. <style>
  330. .script {
  331. position: relative;
  332. margin: 8px;
  333. padding: 12px 10px 5px;
  334. border: 1px solid #ccc;
  335. border-radius: .3rem;
  336. transition: transform .5s;
  337. background: white;
  338. &:hover {
  339. border-color: darkgray;
  340. }
  341. .secondary {
  342. color: gray;
  343. font-size: small;
  344. }
  345. &.disabled,
  346. &.removed {
  347. background: #f0f0f0;
  348. color: #999;
  349. }
  350. &.disabled {
  351. .secondary {
  352. color: darkgray;
  353. }
  354. }
  355. &.removed {
  356. padding-bottom: 10px;
  357. .secondary {
  358. display: none;
  359. }
  360. }
  361. &-buttons {
  362. margin-left: 3.5rem;
  363. align-items: center;
  364. line-height: 1;
  365. color: #3e4651;
  366. > .flex-auto {
  367. margin-left: 1rem;
  368. }
  369. .removed & {
  370. display: none;
  371. }
  372. > .disabled {
  373. color: gainsboro;
  374. }
  375. .icon {
  376. display: block;
  377. }
  378. }
  379. &-info {
  380. margin-left: 3.5rem;
  381. line-height: 1.5;
  382. align-items: center;
  383. > *:not(:last-child) {
  384. margin-right: 8px;
  385. }
  386. }
  387. &-icon {
  388. position: absolute;
  389. width: 3rem;
  390. height: 3rem;
  391. top: 1rem;
  392. .disabled &,
  393. .removed & {
  394. filter: grayscale(.8);
  395. }
  396. .removed & {
  397. width: 2rem;
  398. height: 2rem;
  399. }
  400. }
  401. &-name {
  402. font-weight: bold;
  403. font-size: 1rem;
  404. .disabled & {
  405. color: gray;
  406. }
  407. }
  408. &-author {
  409. > * {
  410. vertical-align: middle;
  411. }
  412. > .ellipsis {
  413. display: inline-block;
  414. max-width: 100px;
  415. }
  416. }
  417. }
  418. .dragging {
  419. position: fixed;
  420. margin: 0;
  421. z-index: 9;
  422. &-placeholder {
  423. visibility: hidden;
  424. }
  425. }
  426. </style>