SE Preview on hover

Shows preview of the linked questions/answers on hover

当前为 2020-09-12 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name SE Preview on hover
  3. // @description Shows preview of the linked questions/answers on hover
  4. // @version 1.1.0
  5. // @author wOxxOm
  6. // @namespace wOxxOm.scripts
  7. // @license MIT License
  8. //
  9. // please use only matches for the previewable targets and make sure the domain
  10. // is extractable via [-.\w] so that it starts with . like .stackoverflow.com
  11. // @match *://*.stackoverflow.com/*
  12. // @match *://*.superuser.com/*
  13. // @match *://*.serverfault.com/*
  14. // @match *://*.askubuntu.com/*
  15. // @match *://*.stackapps.com/*
  16. // @match *://*.mathoverflow.net/*
  17. // @match *://*.stackexchange.com/*
  18. //
  19. // please use only includes for the container sites
  20. // @include *://www.google.com/search*/
  21. // @include /https?:\/\/(www\.)?google(\.com?)?(\.\w\w)?\/(webhp|q|.*?[?#]q=|search).*/
  22. // @include *://*.bing.com/*
  23. // @include *://*.yahoo.com/*
  24. // @include /https?:\/\/(\w+\.)*yahoo.(com|\w\w(\.\w\w)?)\/.*/
  25. //
  26. // @require https://gf.qytechs.cn/scripts/27531/code/LZStringUnsafe.js
  27. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/highlight.min.js
  28. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/autohotkey.min.js
  29. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/autoit.min.js
  30. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/dart.min.js
  31. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/delphi.min.js
  32. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/haskell.min.js
  33. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/moonscript.min.js
  34. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/nsis.min.js
  35. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/powershell.min.js
  36. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/r.min.js
  37. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/vbnet.min.js
  38. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/vbscript-html.min.js
  39. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/vbscript.min.js
  40. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/x86asm.min.js
  41. // @resource HL-style https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/styles/default.min.css
  42. // @resource HL-style-dark https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/styles/monokai.min.css
  43. //
  44. // @grant GM_addStyle
  45. // @grant GM_xmlhttpRequest
  46. // @grant GM_getValue
  47. // @grant GM_setValue
  48. // @grant GM_getResourceText
  49. //
  50. // @connect stackoverflow.com
  51. // @connect superuser.com
  52. // @connect serverfault.com
  53. // @connect askubuntu.com
  54. // @connect stackapps.com
  55. // @connect mathoverflow.net
  56. // @connect stackexchange.com
  57. // @connect sstatic.net
  58. // @connect gravatar.com
  59. // @connect imgur.com
  60. // @connect self
  61. //
  62. // @noframes
  63. // ==/UserScript==
  64.  
  65. /* global hljs LZStringUnsafe */
  66. 'use strict';
  67.  
  68. Promise.resolve().then(() => {
  69. Detector.init();
  70. Security.init();
  71. Urler.init();
  72. Cache.init();
  73. });
  74.  
  75. const PREVIEW_DELAY = 200;
  76. const AUTOHIDE_DELAY = 1000;
  77. const BUSY_CURSOR_DELAY = 300;
  78. // 1 minute for the recently active posts, scales up logarithmically
  79. const CACHE_DURATION = 60e3;
  80.  
  81. const PADDING = 24;
  82. const QUESTION_WIDTH = 677; // unconstrained width of a question's .post_text
  83. const ANSWER_WIDTH = 657; // unconstrained width of an answer's .post_text
  84. const WIDTH = Math.max(QUESTION_WIDTH, ANSWER_WIDTH) + PADDING * 2;
  85. const BORDER = 8;
  86. const TOP_BORDER = 24;
  87. const MIN_HEIGHT = 200;
  88. let colors;
  89. const COLORS_LIGHT = {
  90. body: {
  91. back: '#ffffff',
  92. fore: '#000000',
  93. },
  94. question: {
  95. back: '#5894d8',
  96. fore: '#265184',
  97. foreInv: '#fff',
  98. },
  99. answer: {
  100. back: '#70c350',
  101. fore: '#3f7722',
  102. foreInv: '#fff',
  103. },
  104. deleted: {
  105. back: '#cd9898',
  106. fore: '#b56767',
  107. foreInv: '#fff',
  108. },
  109. closed: {
  110. back: '#ffce5d',
  111. fore: '#c28800',
  112. foreInv: '#fff',
  113. },
  114. };
  115. const COLORS_DARK = {
  116. body: {
  117. back: '#222222',
  118. fore: '#cccccc',
  119. },
  120. question: {
  121. back: '#004696',
  122. fore: '#6abaff',
  123. foreInv: '#004696',
  124. },
  125. answer: {
  126. back: '#004c1b',
  127. fore: '#39c466',
  128. foreInv: '#004c1b',
  129. },
  130. deleted: {
  131. back: '#4d0a0b',
  132. fore: '#b56767',
  133. foreInv: '#fff',
  134. },
  135. closed: {
  136. back: '#4b360a',
  137. fore: '#c28800',
  138. foreInv: '#fff',
  139. },
  140. };
  141. const ID = 'SEpreview';
  142. const EXPANDO = Symbol(ID);
  143.  
  144. const pv = {
  145. /** @type {Target} */
  146. target: null,
  147. /** @type {Element} */
  148. _frame: null,
  149. /** @type {Element} */
  150. get frame() {
  151. if (!this._frame)
  152. Preview.init();
  153. if (!document.contains(this._frame))
  154. document.body.appendChild(this._frame);
  155. return this._frame;
  156. },
  157. set frame(element) {
  158. this._frame = element;
  159. return element;
  160. },
  161. /** @type {Post} */
  162. post: {},
  163. hover: {x: 0, y: 0},
  164. stylesOverride: '',
  165. };
  166.  
  167. class Detector {
  168.  
  169. static init() {
  170. const sites = GM_info.script.matches.map(m => m.match(/[-.\w]+/)[0]);
  171. const rxsSites = 'https?://(\\w*\\.)*(' +
  172. GM_info.script.matches
  173. .map(m => m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.'))
  174. .join('|') +
  175. ')/';
  176. Detector.rxPreviewableSite = new RegExp(rxsSites);
  177. Detector.rxPreviewablePost = new RegExp(rxsSites + '(questions|q|a|posts/comments)/\\d+');
  178. Detector.pageUrls = getBaseUrls(location, Detector.rxPreviewablePost);
  179. Detector.isStackExchangePage = Detector.rxPreviewableSite.test(location);
  180.  
  181. const {
  182. rxPreviewablePost,
  183. isStackExchangePage: isSE,
  184. pageUrls: {base, baseShort},
  185. } = Detector;
  186.  
  187. // array of target elements accumulated in mutation observer
  188. // cleared in attachHoverListener
  189. const moQueue = [];
  190.  
  191. onMutation([{
  192. addedNodes: [document.body],
  193. }]);
  194.  
  195. new MutationObserver(onMutation)
  196. .observe(document.body, {
  197. childList: true,
  198. subtree: true,
  199. });
  200.  
  201. Detector.init = true;
  202.  
  203. function onMutation(mutations) {
  204. const alreadyScheduled = moQueue.length > 0;
  205. for (const {addedNodes} of mutations) {
  206. for (const n of addedNodes) {
  207. if (!n.localName)
  208. continue;
  209. if (n.localName === 'a') {
  210. moQueue.push(n);
  211. continue;
  212. }
  213. // not using ..spreading since there could be 100k links for all we know
  214. // and that might exceed JS engine stack limit which can be pretty low
  215. const targets = n.getElementsByTagName('a');
  216. for (let k = 0, len = targets.length; k < len; k++)
  217. moQueue.push(targets[k]);
  218. if (!isSE)
  219. continue;
  220. if (n.classList.contains('question-summary')) {
  221. moQueue.push(...n.getElementsByClassName('answered'));
  222. moQueue.push(...n.getElementsByClassName('answered-accepted'));
  223. continue;
  224. }
  225. for (const el of n.getElementsByClassName('question-summary')) {
  226. moQueue.push(...el.getElementsByClassName('answered'));
  227. moQueue.push(...el.getElementsByClassName('answered-accepted'));
  228. }
  229. }
  230. }
  231. if (!alreadyScheduled && moQueue.length)
  232. setTimeout(hoverize);
  233. }
  234.  
  235. function hoverize() {
  236. for (const el of moQueue) {
  237. if (el[EXPANDO] instanceof Target)
  238. continue;
  239. if (el.localName === 'a') {
  240. if (isSE && el.classList.contains('js-share-link'))
  241. continue;
  242. const previewable = isPreviewable(el) || !isSE && isEmbeddedUrlPreviewable(el);
  243. if (!previewable)
  244. continue;
  245. const url = Urler.makeHttps(el.href);
  246. if (url.startsWith(base) || url.startsWith(baseShort))
  247. continue;
  248. }
  249. Target.createHoverable(el);
  250. }
  251. moQueue.length = 0;
  252. }
  253.  
  254. function isPreviewable(a) {
  255. let href = false;
  256. const host = '.' + a.hostname;
  257. const hostLen = host.length;
  258. for (const stackSite of sites) {
  259. if (host[hostLen - stackSite.length] === '.' &&
  260. host.endsWith(stackSite) &&
  261. rxPreviewablePost.test(href || (href = a.href)))
  262. return true;
  263. }
  264. }
  265.  
  266. function isEmbeddedUrlPreviewable(a) {
  267. const url = a.href;
  268. let i = url.indexOf('http', 1);
  269. if (i < 0)
  270. return false;
  271. i = (
  272. url.indexOf('http://', i) + 1 ||
  273. url.indexOf('https://', i) + 1 ||
  274. url.indexOf('http%3A%2F%2F', i) + 1 ||
  275. url.indexOf('https%3A%2F%2F', i) + 1
  276. ) - 1;
  277. if (i < 0)
  278. return false;
  279. const j = url.indexOf('&', i);
  280. const embeddedUrl = url.slice(i, j > 0 ? j : undefined);
  281. return rxPreviewablePost.test(embeddedUrl);
  282. }
  283.  
  284. function getBaseUrls(url, rx) {
  285. if (!rx.test(url))
  286. return {};
  287. const base = Urler.makeHttps(RegExp.lastMatch);
  288. return {
  289. base,
  290. baseShort: base.replace('/questions/', '/q/'),
  291. };
  292. }
  293. }
  294. }
  295.  
  296. /**
  297. * @property {Element} element
  298. * @property {Boolean} isLink
  299. * @property {String} url
  300. * @property {Number} timer
  301. * @property {Number} timerCursor
  302. * @property {String} savedCursor
  303. */
  304. class Target {
  305.  
  306. /** @param {Element} el */
  307. static createHoverable(el) {
  308. const target = new Target(el);
  309. Object.defineProperty(el, EXPANDO, {value: target});
  310. el.removeAttribute('title');
  311. el.addEventListener('mouseover', Target._onMouseOver);
  312. return target;
  313. }
  314.  
  315. /** @param {Element} el */
  316. constructor(el) {
  317. this.element = el;
  318. this.isLink = el.localName === 'a';
  319. }
  320.  
  321. release() {
  322. $.off('mousemove', this.element, Target._onMove);
  323. $.off('mouseout', this.element, Target._onHoverEnd);
  324. $.off('mousedown', this.element, Target._onHoverEnd);
  325.  
  326. for (const k in this) {
  327. if (k.startsWith('timer') && this[k] >= 1) {
  328. clearTimeout(this[k]);
  329. this[k] = 0;
  330. }
  331. }
  332. BusyCursor.hide(this);
  333. pv.target = null;
  334. }
  335.  
  336. get url() {
  337. const el = this.element;
  338. if (this.isLink)
  339. return el.href;
  340. const a = $('a', el.closest('.question-summary'));
  341. if (a)
  342. return a.href;
  343. }
  344.  
  345. /** @param {MouseEvent} e */
  346. static _onMouseOver(e) {
  347. if (Util.hasKeyModifiers(e))
  348. return;
  349. const self = /** @type {Target} */ this[EXPANDO];
  350. if (self === Preview.target && Preview.shown() ||
  351. self === pv.target)
  352. return;
  353.  
  354. if (pv.target)
  355. pv.target.release();
  356. pv.target = self;
  357.  
  358. pv.hover.x = e.pageX;
  359. pv.hover.y = e.pageY;
  360.  
  361. $.on('mousemove', this, Target._onMove);
  362. $.on('mouseout', this, Target._onHoverEnd);
  363. $.on('mousedown', this, Target._onHoverEnd);
  364.  
  365. Target._restartTimer(self);
  366. }
  367.  
  368. /** @param {MouseEvent} e */
  369. static _onHoverEnd(e) {
  370. if (e.type === 'mouseout' && e.target !== this)
  371. return;
  372. const self = /** @type {Target} */ this[EXPANDO];
  373. if (pv.xhr && pv.target === self) {
  374. pv.xhr.abort();
  375. pv.xhr = null;
  376. }
  377. self.release();
  378. self.timer = setTimeout(Target._onAbortTimer, AUTOHIDE_DELAY, self);
  379. }
  380.  
  381. /** @param {MouseEvent} e */
  382. static _onMove(e) {
  383. const stoppedMoving =
  384. Math.abs(pv.hover.x - e.pageX) < 2 &&
  385. Math.abs(pv.hover.y - e.pageY) < 2;
  386. if (stoppedMoving) {
  387. pv.hover.x = e.pageX;
  388. pv.hover.y = e.pageY;
  389. Target._restartTimer(this[EXPANDO]);
  390. }
  391. }
  392.  
  393. /** @param {Target} self */
  394. static _restartTimer(self) {
  395. if (self.timer)
  396. clearTimeout(self.timer);
  397. self.timer = setTimeout(Target._onTimer, PREVIEW_DELAY, self);
  398. }
  399.  
  400. /** @param {Target} self */
  401. static _onTimer(self) {
  402. self.timer = 0;
  403. const el = self.element;
  404. if (!el.matches(':hover')) {
  405. self.release();
  406. return;
  407. }
  408. $.off('mousemove', el, Target._onMove);
  409.  
  410. if (self.url)
  411. Preview.start(self);
  412. }
  413.  
  414. /** @param {Target} self */
  415. static _onAbortTimer(self) {
  416. if ((self === pv.target || self === Preview.target) &&
  417. pv.frame && !pv.frame.matches(':hover')) {
  418. pv.target = null;
  419. Preview.hide({fade: true});
  420. }
  421. }
  422. }
  423.  
  424.  
  425. class BusyCursor {
  426.  
  427. /** @param {Target} target */
  428. static schedule(target) {
  429. target.timerCursor = setTimeout(BusyCursor._onTimer, BUSY_CURSOR_DELAY, target);
  430. }
  431.  
  432. /** @param {Target} target */
  433. static hide(target) {
  434. if (target.timerCursor) {
  435. clearTimeout(target.timerCursor);
  436. target.timerCursor = 0;
  437. }
  438. const style = target.element.style;
  439. if (style.cursor === 'wait')
  440. style.cursor = target.savedCursor;
  441. }
  442.  
  443. /** @param {Target} target */
  444. static _onTimer(target) {
  445. target.timerCursor = 0;
  446. target.savedCursor = target.element.style.cursor;
  447. $.setStyle(target.element, ['cursor', 'wait']);
  448. }
  449. }
  450.  
  451.  
  452. class Preview {
  453.  
  454. static init() {
  455. pv.frame = $.create(`#${ID}`, {parent: document.body});
  456. pv.shadow = pv.frame.attachShadow({mode: 'open'});
  457. pv.body = $.create(`body#${ID}-body`, {parent: pv.shadow});
  458.  
  459. const WRAP_AROUND = '(or wrap around to the question)';
  460. const TITLE_PREV = 'Previous answer\n' + WRAP_AROUND;
  461. const TITLE_NEXT = 'Next answer\n' + WRAP_AROUND;
  462. const TITLE_ENTER = 'Return to the question\n(Enter was Return initially)';
  463.  
  464. pv.answersTitle =
  465. $.create(`#${ID}-answers-title`, [
  466. 'Answers:',
  467. $.create('p', [
  468. 'Use ',
  469. $.create('b', {title: TITLE_PREV}),
  470. $.create('b', {title: TITLE_NEXT, attributes: {mirrored: ''}}),
  471. $.create('label', {title: TITLE_ENTER}, 'Enter'),
  472. ' to switch entries',
  473. ]),
  474. ]);
  475.  
  476. $.on('keydown', pv.frame, Preview.onKey);
  477. $.on('keyup', pv.frame, Util.consumeEsc);
  478.  
  479. $.on('mouseover', pv.body, ScrollLock.enable);
  480. $.on('click', pv.body, Preview.onClick);
  481.  
  482. Sizer.init();
  483. Styles.init();
  484. Preview.init = true;
  485. }
  486.  
  487. /** @param {Target} target */
  488. static async start(target) {
  489. Preview.target = target;
  490.  
  491. if (!Security.checked)
  492. Security.check();
  493.  
  494. const {url} = target;
  495.  
  496. let data = Cache.read(url);
  497. if (data) {
  498. const r = await Urler.get(url, {method: 'HEAD'});
  499. const postTime = Util.getResponseDate(r.responseHeaders);
  500. if (postTime >= data.time)
  501. data = null;
  502. }
  503.  
  504. if (!data) {
  505. BusyCursor.schedule(target);
  506. const {finalUrl, responseText: html} = await Urler.get(target.url);
  507. data = {finalUrl, html, unsaved: true};
  508. BusyCursor.hide(target);
  509. }
  510.  
  511. data.url = url;
  512. data.showAnswer = !target.isLink;
  513.  
  514. if (!Preview.prepare(data))
  515. Preview.target = null;
  516. else if (data.unsaved && data.lastActivity >= 1)
  517. Preview.save(data);
  518. }
  519.  
  520. static save({url, finalUrl, html, lastActivity}) {
  521. const inactiveDays = Math.max(0, (Date.now() - lastActivity) / (24 * 3600e3));
  522. const cacheDuration = CACHE_DURATION * Math.pow(Math.log(inactiveDays + 1) + 1, 2);
  523. setTimeout(Cache.write, 1000, {url, finalUrl, html, cacheDuration});
  524. }
  525.  
  526. // data is mutated: its lastActivity property is assigned!
  527. static prepare(data) {
  528. const {finalUrl, html, showAnswer, doc = Util.parseHtml(html)} = data;
  529.  
  530. if (!doc || !doc.head)
  531. return Util.error('no HEAD in the document received for', finalUrl);
  532.  
  533. if (!$('base', doc))
  534. $.create('base', {href: finalUrl, parent: doc.head});
  535.  
  536. let answerId;
  537. if (showAnswer) {
  538. const el = $('[id^="answer-"]', doc);
  539. answerId = el && el.id.match(/\d+/)[0];
  540. } else {
  541. answerId = finalUrl.match(/questions\/\d+\/[^/]+\/(\d+)|$/)[1];
  542. }
  543. const selector = answerId ? '#answer-' + answerId : '#question';
  544. const core = $(`${selector} .${answerId ? 'answer' : 'post'}cell`, doc);
  545. if (!core)
  546. return Util.error('No parsable post found', doc);
  547.  
  548. const isQuestion = !answerId;
  549. const status = isQuestion && $('[role="status"]', core);
  550. const isClosed = status && $('[href*="closed"]', status);
  551. const isDeleted = Boolean(core.closest('.deleted-answer'));
  552. const type = [
  553. isQuestion && 'question' || 'answer',
  554. isDeleted && 'deleted',
  555. isClosed && 'closed',
  556. ].filter(Boolean).join(' ');
  557. const answers = $.all('.answer', doc);
  558. const comments = $(`${selector} .comments`, doc);
  559. const commentsParent = comments.parentElement;
  560. const showMoreComments = $(`${selector} .js-show-link.comments-link`, doc);
  561. const lastActivity = Util.tryCatch(Util.extractTime, $('a[href*="?lastactivity"]', core)) ||
  562. Date.now();
  563. Object.assign(pv, {
  564. finalUrl,
  565. finalUrlOfQuestion: Urler.makeCacheable(finalUrl),
  566. });
  567. /** @typedef Post
  568. * @property {Document} doc
  569. * @property {String} html
  570. * @property {String} selector
  571. * @property {String} type
  572. * @property {String} id
  573. * @property {String} title
  574. * @property {Boolean} isQuestion
  575. * @property {Boolean} isDeleted
  576. * @property {Number} lastActivity
  577. * @property {Number} numAnswers
  578. * @property {Element} core
  579. * @property {Element} comments
  580. * @property {Element[]} answers
  581. * @property {Element[]} renderParts
  582. */
  583. Object.assign(pv.post, {
  584. doc,
  585. html,
  586. core,
  587. selector,
  588. answers,
  589. comments,
  590. type,
  591. isQuestion,
  592. isDeleted,
  593. lastActivity,
  594. id: isQuestion ? Urler.getFirstNumber(finalUrl) : answerId,
  595. title: $('meta[property="og:title"]', doc).content,
  596. numAnswers: answers.length,
  597. renderParts: [
  598. // including the parent so the right CSS kicks in
  599. core,
  600. commentsParent,
  601. ],
  602. });
  603.  
  604. $.remove('script', doc);
  605. // remove the comment actions block
  606. $.remove('.comment-form, [id^="comments-link-"], .hover-only-label', commentsParent);
  607. if (!commentsParent.contains(showMoreComments))
  608. commentsParent.appendChild(showMoreComments);
  609.  
  610. Promise.all([
  611. pv.frame,
  612. Preview.addStyles(),
  613. Security.ready(),
  614. ]).then(Preview.show);
  615.  
  616. data.lastActivity = lastActivity;
  617. return true;
  618. }
  619.  
  620. static show() {
  621. Render.all();
  622.  
  623. const style = getComputedStyle(pv.frame);
  624. if (style.opacity !== '1' || style.display !== 'block') {
  625. $.setStyle(pv.frame, ['display', 'block']);
  626. setTimeout($.setStyle, 0, pv.frame, ['opacity', '1']);
  627. }
  628.  
  629. pv.parts.focus();
  630. }
  631.  
  632. static hide({fade = false} = {}) {
  633. if (Preview.target) {
  634. Preview.target.release();
  635. Preview.target = null;
  636. }
  637.  
  638. pv.body.onmouseover = null;
  639. pv.body.onclick = null;
  640. pv.body.onkeydown = null;
  641.  
  642. if (fade) {
  643. Util.fadeOut(pv.frame)
  644. .then(Preview.eraseBoxIfHidden);
  645. } else {
  646. $.setStyle(pv.frame,
  647. ['opacity', '0'],
  648. ['display', 'none']);
  649. Preview.eraseBoxIfHidden();
  650. }
  651. }
  652.  
  653. static shown() {
  654. return pv.frame.style.opacity === '1';
  655. }
  656.  
  657. /** @param {KeyboardEvent} e */
  658. static onKey(e) {
  659. switch (e.key) {
  660. case 'Escape':
  661. Preview.hide({fade: true});
  662. break;
  663. case 'ArrowUp':
  664. case 'PageUp':
  665. if (pv.parts.scrollTop)
  666. return;
  667. break;
  668. case 'ArrowDown':
  669. case 'PageDown': {
  670. const {scrollTop: t, clientHeight: h, scrollHeight} = pv.parts;
  671. if (t + h < scrollHeight)
  672. return;
  673. break;
  674. }
  675. case 'ArrowLeft':
  676. case 'ArrowRight': {
  677. if (!pv.post.numAnswers)
  678. return;
  679. // current is 0 if isQuestion, 1 is the first answer
  680. const answers = $.all(`#${ID}-answers a`);
  681. const current = pv.post.numAnswers ?
  682. answers.indexOf($('.SEpreviewed')) + 1 :
  683. pv.post.isQuestion ? 0 : 1;
  684. const num = pv.post.numAnswers + 1;
  685. const dir = e.key === 'ArrowLeft' ? -1 : 1;
  686. const toShow = (current + dir + num) % num;
  687. const a = toShow ? answers[toShow - 1] : $(`#${ID}-title`);
  688. a.click();
  689. break;
  690. }
  691. case 'Enter':
  692. if (pv.post.isQuestion)
  693. return;
  694. $(`#${ID}-title`).click();
  695. break;
  696. default:
  697. return;
  698. }
  699. e.preventDefault();
  700. }
  701.  
  702. /** @param {MouseEvent} e */
  703. static onClick(e) {
  704. if (e.target.id === `${ID}-close`) {
  705. Preview.hide();
  706. return;
  707. }
  708.  
  709. const link = e.target.closest('a');
  710. if (!link)
  711. return;
  712.  
  713. if (link.matches('.js-show-link.comments-link')) {
  714. Util.fadeOut(link, 0.5);
  715. Preview.loadComments();
  716. e.preventDefault();
  717. return;
  718. }
  719.  
  720. if (e.button ||
  721. Util.hasKeyModifiers(e) ||
  722. !link.matches('.SEpreviewable')) {
  723. link.target = '_blank';
  724. return;
  725. }
  726.  
  727. e.preventDefault();
  728.  
  729. const {doc} = pv.post;
  730. if (link.id === `${ID}-title`)
  731. Preview.prepare({doc, finalUrl: pv.finalUrlOfQuestion});
  732. else if (link.matches(`#${ID}-answers a`))
  733. Preview.prepare({doc, finalUrl: pv.finalUrlOfQuestion + '/' + Urler.getFirstNumber(link)});
  734. else
  735. Preview.start(new Target(link));
  736. }
  737.  
  738. static eraseBoxIfHidden() {
  739. if (!Preview.shown())
  740. pv.body.textContent = '';
  741. }
  742.  
  743. static setHeight(height) {
  744. const currentHeight = pv.frame.clientHeight;
  745. const borderHeight = pv.frame.offsetHeight - currentHeight;
  746. const newHeight = Math.max(MIN_HEIGHT, Math.min(innerHeight - borderHeight, height));
  747. if (newHeight !== currentHeight)
  748. $.setStyle(pv.frame, ['height', newHeight + 'px']);
  749. }
  750.  
  751. static async addStyles() {
  752. const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
  753. colors = isDark ? COLORS_DARK : COLORS_LIGHT;
  754. pv.body.className = isDark ? 'theme-dark' : '';
  755. Styles.init(isDark);
  756.  
  757. let last = $.create(`style#${ID}-styles.${Styles.REUSABLE}`, {
  758. textContent: pv.stylesOverride,
  759. before: pv.shadow.firstChild,
  760. });
  761.  
  762. if (!pv.styles) {
  763. pv.styles = new Map();
  764. pv.stylesScaled = new Set();
  765. }
  766.  
  767. const toDownload = [];
  768. const sourceElements = $.all('link[rel="stylesheet"], style', pv.post.doc);
  769.  
  770. for (const {href, textContent, localName} of sourceElements) {
  771. const isLink = localName === 'link';
  772. const id = ID + '-style-' + (isLink ? href : await Util.sha256(textContent));
  773. const el = pv.styles.get(id);
  774. if (!el && isLink)
  775. toDownload.push(Urler.get({url: href, context: id}));
  776. last = $.create('style', {
  777. id,
  778. className: Styles.REUSABLE,
  779. textContent: isLink ? $.text(el) : textContent,
  780. after: last,
  781. });
  782. pv.styles.set(id, last);
  783. }
  784.  
  785. const downloaded = await Promise.all(toDownload);
  786.  
  787. for (const {responseText, context: id} of downloaded)
  788. Styles.applyRemScale(id, responseText);
  789.  
  790. if (!pv.remScale) {
  791. pv.remScale = parseFloat(getComputedStyle(pv.body).fontSize) /
  792. parseFloat(getComputedStyle(document.documentElement).fontSize);
  793. if (pv.remScale !== 1)
  794. for (const id of pv.styles.keys())
  795. Styles.applyRemScale(id);
  796. }
  797. }
  798.  
  799. static async loadComments() {
  800. const list = $(`#${pv.post.comments.id} .comments-list`);
  801. const url = new URL(pv.finalUrl).origin +
  802. '/posts/' + pv.post.comments.id.match(/\d+/)[0] + '/comments';
  803. list.innerHTML = (await Urler.get(url)).responseText;
  804. $.remove('.hover-only-label', list);
  805.  
  806. const oldIds = new Set([...list.children].map(e => e.id));
  807. for (const cmt of list.children) {
  808. if (!oldIds.has(cmt.id))
  809. cmt.classList.add('new-comment-highlight');
  810. }
  811.  
  812. $.setStyle(list.closest('.comments'), ['display', 'block']);
  813. Render.previewableLinks(list);
  814. Render.hoverableUsers(list);
  815. }
  816. }
  817.  
  818.  
  819. class Render {
  820.  
  821. static all() {
  822. pv.frame.classList.toggle(`${ID}-hasAnswerShelf`, pv.post.numAnswers > 0);
  823. pv.frame.setAttribute(`${ID}-type`, pv.post.type);
  824. pv.body.setAttribute(`${ID}-type`, pv.post.type);
  825.  
  826. $.create(`a#${ID}-title.SEpreviewable`, {
  827. href: pv.finalUrlOfQuestion,
  828. textContent: pv.post.title,
  829. parent: pv.body,
  830. });
  831.  
  832. $.create(`#${ID}-close`, {
  833. title: 'Or press Esc key while the preview is focused (also when just shown)',
  834. parent: pv.body,
  835. });
  836.  
  837. $.create(`#${ID}-meta`, {
  838. parent: pv.body,
  839. onmousedown: Sizer.onMouseDown,
  840. children: [
  841. Render._votes(),
  842. pv.post.isQuestion
  843. ? Render._questionMeta()
  844. : Render._answerMeta(),
  845. ],
  846. });
  847.  
  848. Render.previewableLinks(pv.post.doc);
  849.  
  850. pv.post.answerShelf = pv.post.answers.map(Render._answer);
  851. if (Security.noImages)
  852. Security.embedImages(...pv.post.renderParts);
  853.  
  854. pv.parts = $.create(`#${ID}-parts`, {
  855. className: pv.post.isDeleted ? 'deleted-answer' : '',
  856. tabIndex: 0,
  857. scrollTop: 0,
  858. parent: pv.body,
  859. children: pv.post.renderParts,
  860. });
  861.  
  862. Render.hoverableUsers(pv.parts);
  863.  
  864. if (pv.post.numAnswers) {
  865. $.create(`#${ID}-answers`, {parent: pv.body}, [
  866. pv.answersTitle,
  867. pv.post.answerShelf,
  868. ]);
  869. } else {
  870. $.remove(`#${ID}-answers`, pv.body);
  871. }
  872.  
  873. // delinkify/remove non-functional items in post-menu
  874. $.remove('.js-share-link, .flag-post-link', pv.body);
  875. for (const a of $.all('.post-menu a:not(.edit-post)')) {
  876. if (a.children.length)
  877. $.create('span', {before: a}, a.childNodes);
  878. a.remove();
  879. }
  880.  
  881. // add a timeline link
  882. $.appendChildren($('.post-menu'), [
  883. $.create('span.lsep'),
  884. $.create('a', {href: `/posts/${pv.post.id}/timeline`}, 'timeline'),
  885. ]);
  886.  
  887. // prettify code blocks
  888. hljs.configure({
  889. languages: $.all('.post-taglist .post-tag', pv.post.doc).map($.text),
  890. });
  891. $.all('pre code').forEach(e =>
  892. hljs.highlightBlock(e.parentElement));
  893.  
  894. const leftovers = $.all('style, link, script, .post-menu .lsep + .lsep');
  895. for (const el of leftovers) {
  896. if (el.classList.contains(Styles.REUSABLE))
  897. el.classList.remove(Styles.REUSABLE);
  898. else
  899. el.remove();
  900. }
  901.  
  902. pv.post.html = null;
  903. pv.post.core = null;
  904. pv.post.renderParts = null;
  905. pv.post.answers = null;
  906. pv.post.answerShelf = null;
  907. }
  908.  
  909. /** @param {Element} container */
  910. static previewableLinks(container) {
  911. for (const a of $.all('a:not(.SEpreviewable)', container)) {
  912. let href = a.getAttribute('href');
  913. if (!href)
  914. continue;
  915. if (!href.includes('://')) {
  916. href = a.href;
  917. a.setAttribute('href', href);
  918. }
  919. if (Detector.rxPreviewablePost.test(href)) {
  920. a.removeAttribute('title');
  921. a.classList.add('SEpreviewable');
  922. }
  923. }
  924. }
  925.  
  926. /** @param {Element} container */
  927. static hoverableUsers(container) {
  928. for (const a of $.all('a[href*="/users/"]', container)) {
  929. if (Detector.rxPreviewableSite.test(a.href) &&
  930. a.pathname.match(/^\/users\/\d+/)) {
  931. a.onmouseover = UserCard.onUserLinkHovered;
  932. a.classList.add(`${ID}-userLink`);
  933. }
  934. }
  935. }
  936.  
  937. /** @param {Element} el */
  938. static _answer(el) {
  939. const shortUrl = $('.js-share-link', el).href.replace(/(\d+)\/\d+/, '$1');
  940. const extraClasses =
  941. (el.matches(pv.post.selector) ? ' SEpreviewed' : '') +
  942. (el.matches('.deleted-answer') ? ' deleted-answer' : '') +
  943. (el.matches('.accepted-answer') ? ` ${ID}-accepted` : '');
  944. const author = $('.post-signature:last-child', el);
  945. const title =
  946. $.text('.user-details a', author) +
  947. ' (rep ' +
  948. $.text('.reputation-score', author) +
  949. ')\n' +
  950. $.text('.user-action-time', author);
  951. let gravatar = $('img, .anonymous-gravatar, .community-wiki', author);
  952. if (gravatar && Security.noImages)
  953. Security.embedImages(gravatar);
  954. if (gravatar && gravatar.src)
  955. gravatar = $.create('img', {src: gravatar.src});
  956. const a = $.create('a', {
  957. href: shortUrl,
  958. title: title,
  959. className: 'SEpreviewable' + extraClasses,
  960. textContent: $.text('.js-vote-count', el).replace(/^0$/, '\xA0') + ' ',
  961. children: gravatar,
  962. });
  963. return [a, ' '];
  964. }
  965.  
  966. static _votes() {
  967. const votes = $.text('.js-vote-count', pv.post.core.closest('.post-layout'));
  968. if (Number(votes))
  969. return $.create('b', `${votes} vote${Math.abs(votes) >= 2 ? 's' : ''}`);
  970. }
  971. static _questionMeta() {
  972. try {
  973. return [...$('time', pv.post.doc).closest('.grid').children]
  974. .map(el => el.textContent.trim())
  975. .map((s, i) => (i ? s.toLowerCase() : s))
  976. .join(', ');
  977. } catch (e) {
  978. return '';
  979. }
  980. }
  981.  
  982. static _answerMeta() {
  983. return $.all('.user-action-time', pv.post.core.closest('.answer'))
  984. .reverse()
  985. .map($.text)
  986. .join(', ');
  987. }
  988. }
  989.  
  990.  
  991. class UserCard {
  992.  
  993. _fadeIn() {
  994. this._retakeId(this);
  995. $.setStyle(this.element,
  996. ['opacity', '0'],
  997. ['display', 'block']);
  998. this.timer = setTimeout(() => {
  999. if (this.timer)
  1000. $.setStyle(this.element, ['opacity', '1']);
  1001. });
  1002. }
  1003.  
  1004. _retakeId() {
  1005. if (this.element.id !== 'user-menu') {
  1006. const oldCard = $('#user-menu');
  1007. if (oldCard)
  1008. oldCard.id = oldCard.style.display = '';
  1009. this.element.id = 'user-menu';
  1010. }
  1011. }
  1012.  
  1013. // 'this' is the hoverable link enclosing the user's name/avatar
  1014. static onUserLinkHovered() {
  1015. clearTimeout(this[EXPANDO]);
  1016. this[EXPANDO] = setTimeout(UserCard._show, PREVIEW_DELAY * 2, this);
  1017. }
  1018.  
  1019. /** @param {HTMLAnchorElement} a */
  1020. static async _show(a) {
  1021. if (!a.matches(':hover'))
  1022. return;
  1023. const el = a.nextElementSibling;
  1024. const card = el && el.matches(`.${ID}-userCard`) && el[EXPANDO] ||
  1025. await UserCard._create(a);
  1026. card._fadeIn();
  1027. }
  1028.  
  1029. /** @param {HTMLAnchorElement} a */
  1030. static async _create(a) {
  1031. const url = a.origin + '/users/user-info/' + Urler.getFirstNumber(a);
  1032. let {html} = Cache.read(url) || {};
  1033. if (!html) {
  1034. html = (await Urler.get(url)).responseText;
  1035. Cache.write({url, html, cacheDuration: CACHE_DURATION * 100});
  1036. }
  1037.  
  1038. const dom = Util.parseHtml(html);
  1039. if (Security.noImages)
  1040. Security.embedImages(dom);
  1041.  
  1042. const b = a.getBoundingClientRect();
  1043. const pb = pv.parts.getBoundingClientRect();
  1044. const left = Math.min(b.left - 20, pb.right - 350) - pb.left + 'px';
  1045. const isClipped = b.bottom + 100 > pb.bottom;
  1046.  
  1047. const el = $.create(`#user-menu-tmp.${ID}-userCard`, {
  1048. attributes: {
  1049. style: `left: ${left} !important;` +
  1050. (isClipped ? 'margin-top: -5rem !important;' : ''),
  1051. },
  1052. onmouseout: UserCard._onMouseOut,
  1053. children: dom.body.children,
  1054. after: a,
  1055. });
  1056.  
  1057. const card = new UserCard(el);
  1058. Object.defineProperty(el, EXPANDO, {value: card});
  1059. card.element = el;
  1060. return card;
  1061. }
  1062.  
  1063. /** @param {MouseEvent} e */
  1064. static _onMouseOut(e) {
  1065. if (this.matches(':hover') ||
  1066. this.style.opacity === '0' /* fading out already */)
  1067. return;
  1068.  
  1069. const self = /** @type {UserCard} */ this[EXPANDO];
  1070. clearTimeout(self.timer);
  1071. self.timer = 0;
  1072.  
  1073. Util.fadeOut(this);
  1074. }
  1075. }
  1076.  
  1077.  
  1078. class Sizer {
  1079.  
  1080. static init() {
  1081. Preview.setHeight(GM_getValue('height', innerHeight / 3) >> 0);
  1082. }
  1083.  
  1084. /** @param {MouseEvent} e */
  1085. static onMouseDown(e) {
  1086. if (e.button !== 0 || Util.hasKeyModifiers(e))
  1087. return;
  1088. Sizer._heightDelta = innerHeight - e.clientY - pv.frame.clientHeight;
  1089. $.on('mousemove', document, Sizer._onMouseMove);
  1090. $.on('mouseup', document, Sizer._onMouseUp);
  1091. }
  1092.  
  1093. /** @param {MouseEvent} e */
  1094. static _onMouseMove(e) {
  1095. Preview.setHeight(innerHeight - e.clientY - Sizer._heightDelta);
  1096. getSelection().removeAllRanges();
  1097. }
  1098.  
  1099. /** @param {MouseEvent} e */
  1100. static _onMouseUp(e) {
  1101. GM_setValue('height', pv.frame.clientHeight);
  1102. $.off('mouseup', document, Sizer._onMouseUp);
  1103. $.off('mousemove', document, Sizer._onMouseMove);
  1104. }
  1105. }
  1106.  
  1107.  
  1108. class ScrollLock {
  1109.  
  1110. static enable() {
  1111. if (ScrollLock.active)
  1112. return;
  1113. ScrollLock.active = true;
  1114. ScrollLock.x = scrollX;
  1115. ScrollLock.y = scrollY;
  1116. $.on('mouseover', document.body, ScrollLock._onMouseOver);
  1117. $.on('scroll', document, ScrollLock._onScroll);
  1118. }
  1119.  
  1120. static disable() {
  1121. ScrollLock.active = false;
  1122. $.off('mouseover', document.body, ScrollLock._onMouseOver);
  1123. $.off('scroll', document, ScrollLock._onScroll);
  1124. }
  1125.  
  1126. static _onMouseOver() {
  1127. if (ScrollLock.active)
  1128. ScrollLock.disable();
  1129. }
  1130.  
  1131. static _onScroll() {
  1132. scrollTo(ScrollLock.x, ScrollLock.y);
  1133. }
  1134. }
  1135.  
  1136.  
  1137. class Security {
  1138.  
  1139. static init() {
  1140. if (Detector.isStackExchangePage) {
  1141. Security.checked = true;
  1142. Security.check = null;
  1143. }
  1144. Security.init = true;
  1145. }
  1146.  
  1147. static async check() {
  1148. Security.noImages = false;
  1149. Security._resolveOnReady = [];
  1150. Security._imageCache = new Map();
  1151.  
  1152. const {headers} = await fetch(location.href, {
  1153. method: 'HEAD',
  1154. cache: 'force-cache',
  1155. mode: 'same-origin',
  1156. credentials: 'same-origin',
  1157. });
  1158. const csp = headers.get('Content-Security-Policy');
  1159. const imgSrc = /(?:^|[\s;])img-src\s+([^;]+)/i.test(csp) && RegExp.$1.trim();
  1160. if (imgSrc)
  1161. Security.noImages = !/(^\s)(\*|https?:)(\s|$)/.test(imgSrc);
  1162.  
  1163. Security._resolveOnReady.forEach(fn => fn());
  1164. Security._resolveOnReady = null;
  1165. Security.checked = true;
  1166. Security.check = null;
  1167. }
  1168.  
  1169. /** @return Promise<void> */
  1170. static ready() {
  1171. return Security.checked ?
  1172. Promise.resolve() :
  1173. new Promise(done => Security._resolveOnReady.push(done));
  1174. }
  1175.  
  1176. static embedImages(...containers) {
  1177. for (const container of containers) {
  1178. if (!container)
  1179. continue;
  1180. if (Util.isIterable(container)) {
  1181. Security.embedImages(...container);
  1182. continue;
  1183. }
  1184. if (container.localName === 'img') {
  1185. Security._embedImage(container);
  1186. continue;
  1187. }
  1188. for (const img of container.getElementsByTagName('img'))
  1189. Security._embedImage(img);
  1190. }
  1191. }
  1192.  
  1193. static _embedImage(img) {
  1194. const src = img.src;
  1195. if (!src || src.startsWith('data:'))
  1196. return;
  1197. const data = Security._imageCache.get(src);
  1198. const alreadyFetching = Array.isArray(data);
  1199. if (alreadyFetching) {
  1200. data.push(img);
  1201. } else if (data) {
  1202. img.src = data;
  1203. return;
  1204. } else {
  1205. Security._imageCache.set(src, [img]);
  1206. Security._fetchImage(src);
  1207. }
  1208. $.setStyle(img, ['visibility', 'hidden']);
  1209. img.dataset.src = src;
  1210. img.removeAttribute('src');
  1211. }
  1212.  
  1213. static async _fetchImage(src) {
  1214. const r = await Urler.get({url: src, responseType: 'blob'});
  1215. const type = Util.getResponseMimeType(r.responseHeaders);
  1216. const blob = r.response;
  1217. const blobType = blob.type;
  1218. let dataUri = await Util.blobToBase64(blob);
  1219. if (blobType !== type)
  1220. dataUri = 'data:' + type + dataUri.slice(dataUri.indexOf(';'));
  1221.  
  1222. const images = Security._imageCache.get(src);
  1223. Security._imageCache.set(src, dataUri);
  1224.  
  1225. let detached = false;
  1226. for (const el of images) {
  1227. el.src = dataUri;
  1228. el.style.removeProperty('visibility');
  1229. if (!detached && el.ownerDocument !== document)
  1230. detached = true;
  1231. }
  1232.  
  1233. if (detached) {
  1234. for (const el of $.all(`img[data-src="${src}"]`)) {
  1235. el.src = dataUri;
  1236. el.style.removeProperty('visibility');
  1237. }
  1238. }
  1239. }
  1240. }
  1241.  
  1242.  
  1243. // eslint-disable-next-line no-redeclare
  1244. class Cache {
  1245.  
  1246. static init() {
  1247. Cache.timers = new Map();
  1248. setTimeout(Cache._cleanup, 10e3);
  1249. }
  1250.  
  1251. static read(url) {
  1252. const keyUrl = Urler.makeCacheable(url);
  1253. const [time, expires, finalUrl = url] = (localStorage[keyUrl] || '').split('\t');
  1254. const keyFinalUrl = Urler.makeCacheable(finalUrl);
  1255. return expires > Date.now() && {
  1256. time,
  1257. finalUrl,
  1258. html: LZStringUnsafe.decompressFromUTF16(localStorage[keyFinalUrl + '\thtml']),
  1259. };
  1260. }
  1261.  
  1262. // standard keyUrl = time,expiry
  1263. // keyUrl\thtml = html
  1264. // redirected keyUrl = time,expiry,finalUrl
  1265. // keyFinalUrl = time,expiry
  1266. // keyFinalUrl\thtml = html
  1267. static write({url, finalUrl, html, cacheDuration = CACHE_DURATION}) {
  1268.  
  1269. cacheDuration = Math.max(CACHE_DURATION, Math.min(0x7FFF0000, cacheDuration >> 0));
  1270. finalUrl = (finalUrl || url).replace(/[?#].*/, '');
  1271.  
  1272. const keyUrl = Urler.makeCacheable(url);
  1273. const keyFinalUrl = Urler.makeCacheable(finalUrl);
  1274. const lz = LZStringUnsafe.compressToUTF16(html);
  1275.  
  1276. if (!Util.tryCatch(Cache._writeRaw, keyFinalUrl + '\thtml', lz)) {
  1277. Cache._cleanup({aggressive: true});
  1278. if (!Util.tryCatch(Cache._writeRaw, keyFinalUrl + '\thtml', lz))
  1279. return Util.error('localStorage write error');
  1280. }
  1281.  
  1282. const time = Date.now();
  1283. const expiry = time + cacheDuration;
  1284. localStorage[keyFinalUrl] = time + '\t' + expiry;
  1285. if (keyUrl !== keyFinalUrl)
  1286. localStorage[keyUrl] = time + '\t' + expiry + '\t' + finalUrl;
  1287.  
  1288. const t = setTimeout(Cache._delete, cacheDuration + 1000,
  1289. keyUrl,
  1290. keyFinalUrl,
  1291. keyFinalUrl + '\thtml');
  1292.  
  1293. for (const url of [keyUrl, keyFinalUrl]) {
  1294. clearTimeout(Cache.timers.get(url));
  1295. Cache.timers.set(url, t);
  1296. }
  1297. }
  1298.  
  1299. static _writeRaw(k, v) {
  1300. localStorage[k] = v;
  1301. return true;
  1302. }
  1303.  
  1304. static _delete(...keys) {
  1305. for (const k of keys) {
  1306. delete localStorage[k];
  1307. Cache.timers.delete(k);
  1308. }
  1309. }
  1310.  
  1311. static _cleanup({aggressive = false} = {}) {
  1312. for (const k in localStorage) {
  1313. if ((k.startsWith('http://') || k.startsWith('https://')) &&
  1314. !k.includes('\t')) {
  1315. const [, expires, url] = (localStorage[k] || '').split('\t');
  1316. if (Number(expires) > Date.now() && !aggressive)
  1317. break;
  1318. if (url) {
  1319. delete localStorage[url];
  1320. Cache.timers.delete(url);
  1321. }
  1322. delete localStorage[(url || k) + '\thtml'];
  1323. delete localStorage[k];
  1324. Cache.timers.delete(k);
  1325. }
  1326. }
  1327. }
  1328. }
  1329.  
  1330.  
  1331. class Urler {
  1332.  
  1333. static init() {
  1334. Urler.xhr = null;
  1335. Urler.xhrNoSSL = new Set();
  1336. Urler.init = true;
  1337. }
  1338.  
  1339. static getFirstNumber(url) {
  1340. if (typeof url === 'string')
  1341. url = new URL(url);
  1342. return url.pathname.match(/\/(\d+)/)[1];
  1343. }
  1344.  
  1345. static makeHttps(url) {
  1346. if (!url)
  1347. return '';
  1348. if (url.startsWith('http:'))
  1349. return 'https:' + url.slice(5);
  1350. return url;
  1351. }
  1352.  
  1353. // strips queries and hashes and anything after the main part
  1354. // https://site/questions/NNNNNN/title/
  1355. static makeCacheable(url) {
  1356. return url
  1357. .replace(/(\/q(?:uestions)?\/\d+\/[^/]+).*/, '$1')
  1358. .replace(/(\/a(?:nswers)?\/\d+).*/, '$1')
  1359. .replace(/[?#].*$/, '');
  1360. }
  1361.  
  1362. static get(options) {
  1363. if (!options.url)
  1364. options = {url: options, method: 'GET'};
  1365. if (!options.method)
  1366. options = Object.assign({method: 'GET'}, options);
  1367.  
  1368. let url = options.url;
  1369. const hostname = new URL(url).hostname;
  1370.  
  1371. if (Urler.xhrNoSSL.has(hostname)) {
  1372. url = url.replace(/^https/, 'http');
  1373. } else {
  1374. url = Urler.makeHttps(url);
  1375. const _onerror = options.onerror;
  1376. options.onerror = () => {
  1377. options.onerror = _onerror;
  1378. options.url = url.replace(/^https/, 'http');
  1379. Urler.xhrNoSSL.add(hostname);
  1380. return Urler.get(options);
  1381. };
  1382. }
  1383.  
  1384. return new Promise(resolve => {
  1385. let xhr;
  1386. options.onload = r => {
  1387. if (pv.xhr === xhr)
  1388. pv.xhr = null;
  1389. resolve(r);
  1390. };
  1391. options.url = url;
  1392. xhr = pv.xhr = GM_xmlhttpRequest(options);
  1393. });
  1394. }
  1395. }
  1396.  
  1397.  
  1398. class Util {
  1399.  
  1400. static tryCatch(fn, ...args) {
  1401. try {
  1402. return fn(...args);
  1403. } catch (e) {}
  1404. }
  1405.  
  1406. static isIterable(o) {
  1407. return typeof o === 'object' && Symbol.iterator in o;
  1408. }
  1409.  
  1410. static parseHtml(html) {
  1411. if (!Util.parser)
  1412. Util.parser = new DOMParser();
  1413. return Util.parser.parseFromString(html, 'text/html');
  1414. }
  1415.  
  1416. static extractTime(element) {
  1417. return new Date(element.title).getTime();
  1418. }
  1419.  
  1420. static getResponseMimeType(headers) {
  1421. return headers.match(/^\s*content-type:\s*(.*)|$/mi)[1] ||
  1422. 'image/png';
  1423. }
  1424.  
  1425. static getResponseDate(headers) {
  1426. try {
  1427. return new Date(headers.match(/^\s*date:\s*(.*)/mi)[1]);
  1428. } catch (e) {}
  1429. }
  1430.  
  1431. static blobToBase64(blob) {
  1432. return new Promise((resolve, reject) => {
  1433. const reader = new FileReader();
  1434. reader.onerror = reject;
  1435. reader.onload = e => resolve(e.target.result);
  1436. reader.readAsDataURL(blob);
  1437. });
  1438. }
  1439.  
  1440. static async sha256(str) {
  1441. if (!pv.utf8encoder)
  1442. pv.utf8encoder = new TextEncoder('utf-8');
  1443. const buf = await crypto.subtle.digest('SHA-256', pv.utf8encoder.encode(str));
  1444. const blob = new Blob([buf]);
  1445. const url = await Util.blobToBase64(blob);
  1446. return url.slice(url.indexOf(',') + 1);
  1447. }
  1448.  
  1449. /** @param {KeyboardEvent} e */
  1450. static hasKeyModifiers(e) {
  1451. return e.ctrlKey || e.altKey || e.shiftKey || e.metaKey;
  1452. }
  1453.  
  1454. static fadeOut(el, transition) {
  1455. return new Promise(resolve => {
  1456. if (transition) {
  1457. if (typeof transition === 'number')
  1458. transition = `opacity ${transition}s ease-in-out`;
  1459. $.setStyle(el, ['transition', transition]);
  1460. setTimeout(doFadeOut);
  1461. } else {
  1462. doFadeOut();
  1463. }
  1464. function doFadeOut() {
  1465. $.setStyle(el, ['opacity', '0']);
  1466. $.on('transitionend', el, done);
  1467. $.on('visibilitychange', el, done);
  1468. }
  1469. function done() {
  1470. $.off('transitionend', el, done);
  1471. $.off('visibilitychange', el, done);
  1472. if (el.style.opacity === '0')
  1473. $.setStyle(el, ['display', 'none']);
  1474. resolve();
  1475. }
  1476. });
  1477. }
  1478.  
  1479. /** @param {KeyboardEvent} e */
  1480. static consumeEsc(e) {
  1481. if (e.key === 'Escape')
  1482. e.preventDefault();
  1483. }
  1484.  
  1485. static error(...args) {
  1486. console.error(GM_info.script.name, ...args);
  1487. }
  1488. }
  1489.  
  1490.  
  1491. class Styles {
  1492.  
  1493. static init(isDark) {
  1494. if (Styles.isDark === isDark)
  1495. return;
  1496.  
  1497. Styles.isDark = isDark;
  1498. Styles.REUSABLE = `${ID}-reusable`;
  1499.  
  1500. const KBD_COLOR = '#0008';
  1501.  
  1502. // language=HTML
  1503. const SVG_ARROW = btoa(`
  1504. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
  1505. <path stroke="${KBD_COLOR}" stroke-width="3" fill="none"
  1506. d="M2.5,8.5H15 M9,2L2.5,8.5L9,15"/>
  1507. </svg>`
  1508. .replace(/>\s+</g, '><')
  1509. .replace(/[\r\n]/g, ' ')
  1510. .replace(/\s\s+/g, ' ')
  1511. .trim()
  1512. );
  1513.  
  1514. const IMPORTANT = '!important;';
  1515.  
  1516. // language=CSS
  1517. pv.stylesOverride = [
  1518. `
  1519. :host {
  1520. all: initial;
  1521. border-color: transparent;
  1522. display: none;
  1523. opacity: 0;
  1524. height: 33%;
  1525. transition: opacity .25s cubic-bezier(.88,.02,.92,.66),
  1526. border-color .25s ease-in-out;
  1527. }
  1528. `,
  1529.  
  1530. `
  1531. :host {
  1532. box-sizing: content-box;
  1533. width: ${WIDTH}px;
  1534. min-height: ${MIN_HEIGHT}px;
  1535. position: fixed;
  1536. right: 0;
  1537. bottom: 0;
  1538. padding: 0;
  1539. margin: 0;
  1540. background: white;
  1541. box-shadow: 0 0 100px rgba(0,0,0,0.5);
  1542. z-index: 999999;
  1543. border-width: ${TOP_BORDER}px ${BORDER}px ${BORDER}px;
  1544. border-style: solid;
  1545. }
  1546. :host(:not([style*="opacity: 1"])) {
  1547. pointer-events: none;
  1548. }
  1549. :host([\\type$="question"].\\hasAnswerShelf) {
  1550. border-image: linear-gradient(
  1551. ${colors.question.back} 66%,
  1552. ${colors.answer.back}) 1 1;
  1553. }
  1554. `.replace(/;/g, IMPORTANT),
  1555.  
  1556. ...Object.entries(colors).map(([type, colors]) => `
  1557. :host([\\type$="${type}"]) {
  1558. border-color: ${colors.back} !important;
  1559. }
  1560. `),
  1561.  
  1562. `
  1563. #\\body {
  1564. min-width: unset!important;
  1565. box-shadow: none!important;
  1566. padding: 0!important;
  1567. margin: 0!important;
  1568. background: ${colors.body.back}!important;
  1569. color: ${colors.body.fore}!important;
  1570. display: flex;
  1571. flex-direction: column;
  1572. height: 100%;
  1573. }
  1574.  
  1575. #\\title {
  1576. all: unset;
  1577. display: block;
  1578. padding: 12px ${PADDING}px;
  1579. font-weight: bold;
  1580. font-size: 18px;
  1581. line-height: 1.2;
  1582. cursor: pointer;
  1583. }
  1584. #\\title:hover {
  1585. text-decoration: underline;
  1586. text-decoration-skip: ink;
  1587. }
  1588. #\\title:hover + #\\meta {
  1589. opacity: 1.0;
  1590. }
  1591.  
  1592. #\\meta {
  1593. position: absolute;
  1594. font: bold 14px/${TOP_BORDER}px sans-serif;
  1595. height: ${TOP_BORDER}px;
  1596. top: -${TOP_BORDER}px;
  1597. left: -${BORDER}px;
  1598. right: ${BORDER * 2}px;
  1599. padding: 0 0 0 ${BORDER + PADDING}px;
  1600. display: flex;
  1601. align-items: center;
  1602. cursor: s-resize;
  1603. }
  1604. #\\meta b {
  1605. height: ${TOP_BORDER}px;
  1606. display: inline-block;
  1607. padding: 0 6px;
  1608. margin-left: -6px;
  1609. margin-right: 3px;
  1610. }
  1611.  
  1612. #\\close {
  1613. position: absolute;
  1614. top: -${TOP_BORDER}px;
  1615. right: -${BORDER}px;
  1616. width: ${BORDER * 3}px;
  1617. flex: none;
  1618. cursor: pointer;
  1619. padding: .5ex 1ex;
  1620. font: normal 15px/1.0 sans-serif;
  1621. color: #fff8;
  1622. }
  1623. #\\close:after {
  1624. content: "x";
  1625. }
  1626. #\\close:active {
  1627. background-color: rgba(0,0,0,.2);
  1628. }
  1629. #\\close:hover {
  1630. background-color: rgba(0,0,0,.1);
  1631. }
  1632.  
  1633. #\\parts {
  1634. position: relative;
  1635. overflow-y: overlay; /* will replace with scrollbar-gutter once it's implemented */
  1636. overflow-x: hidden;
  1637. flex-grow: 2;
  1638. outline: none;
  1639. margin: 0;
  1640. }
  1641. [\\type^="question"] #\\parts {
  1642. padding: ${(WIDTH - QUESTION_WIDTH) / 2}px !important;
  1643. }
  1644. [\\type^="answer"] #\\parts {
  1645. padding: ${(WIDTH - ANSWER_WIDTH) / 2}px !important;
  1646. }
  1647. #\\parts > .question-status {
  1648. margin: -${PADDING}px -${PADDING}px ${PADDING}px;
  1649. padding-left: ${PADDING}px;
  1650. }
  1651. #\\parts .question-originals-of-duplicate {
  1652. margin: -${PADDING}px -${PADDING}px ${PADDING}px;
  1653. padding: ${PADDING / 2 >> 0}px ${PADDING}px;
  1654. }
  1655. #\\parts > .question-status h2 {
  1656. font-weight: normal;
  1657. }
  1658. #\\parts a.SEpreviewable {
  1659. text-decoration: underline !important;
  1660. text-decoration-skip: ink;
  1661. }
  1662.  
  1663. #\\parts .comment-actions {
  1664. width: 20px !important;
  1665. }
  1666. #\\parts .comment-edit,
  1667. #\\parts .delete-tag,
  1668. #\\parts .comment-actions > :not(.comment-score) {
  1669. display: none;
  1670. }
  1671. #\\parts .comments {
  1672. border-top: none;
  1673. }
  1674. #\\parts .comments .comment:last-child .comment-text {
  1675. border-bottom: none;
  1676. }
  1677. #\\parts .comments .new-comment-highlight .comment-text {
  1678. -webkit-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1679. -moz-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1680. animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1681. }
  1682. #\\parts .post-menu > span {
  1683. opacity: .35;
  1684. }
  1685.  
  1686. #\\parts #user-menu {
  1687. position: absolute;
  1688. }
  1689. .\\userCard {
  1690. position: absolute;
  1691. display: none;
  1692. transition: opacity .25s cubic-bezier(.88,.02,.92,.66) .5s;
  1693. margin-top: -3rem;
  1694. }
  1695. #\\parts .wmd-preview a:not(.post-tag),
  1696. #\\parts .postcell a:not(.post-tag),
  1697. #\\parts .comment-copy a:not(.post-tag) {
  1698. border-bottom: none;
  1699. }
  1700.  
  1701. #\\answers-title {
  1702. margin: .5ex 1ex 0 0;
  1703. font-size: 18px;
  1704. line-height: 1.0;
  1705. float: left;
  1706. }
  1707. #\\answers-title p {
  1708. font-size: 11px;
  1709. font-weight: normal;
  1710. max-width: 8em;
  1711. line-height: 1.0;
  1712. margin: 1ex 0 0 0;
  1713. padding: 0;
  1714. }
  1715. #\\answers-title b,
  1716. #\\answers-title label {
  1717. background: linear-gradient(#fff8 30%, #fff);
  1718. width: 10px;
  1719. height: 10px;
  1720. padding: 2px;
  1721. margin-right: 2px;
  1722. box-shadow: 0 1px 3px #0008;
  1723. border-radius: 3px;
  1724. font-weight: normal;
  1725. display: inline-block;
  1726. vertical-align: middle;
  1727. }
  1728. #\\answers-title b::after {
  1729. content: "";
  1730. display: block;
  1731. width: 100%;
  1732. height: 100%;
  1733. background: url('data:image/svg+xml;base64,${SVG_ARROW}') no-repeat center;
  1734. }
  1735. #\\answers-title b[mirrored]::after {
  1736. transform: scaleX(-1);
  1737. }
  1738. #\\answers-title label {
  1739. width: auto;
  1740. color: ${KBD_COLOR};
  1741. }
  1742.  
  1743. #\\answers {
  1744. all: unset;
  1745. display: block;
  1746. padding: 10px 10px 10px ${PADDING}px;
  1747. font-weight: bold;
  1748. line-height: 1.0;
  1749. border-top: 4px solid ${colors.answer.back}5e;
  1750. background-color: ${colors.answer.back}5e;
  1751. color: ${colors.answer.fore};
  1752. word-break: break-word;
  1753. }
  1754. #\\answers a {
  1755. color: ${colors.answer.fore};
  1756. text-decoration: none;
  1757. font-size: 11px;
  1758. font-family: monospace;
  1759. width: 32px !important;
  1760. display: inline-block;
  1761. position: relative;
  1762. vertical-align: top;
  1763. margin: 0 1ex 1ex 0;
  1764. padding: 0 0 1.1ex 0;
  1765. }
  1766. [\\type*="deleted"] #\\answers a {
  1767. color: ${colors.deleted.fore};
  1768. }
  1769. #\\answers img {
  1770. width: 32px;
  1771. height: 32px;
  1772. }
  1773. #\\answers a.deleted-answer {
  1774. color: ${colors.deleted.fore};
  1775. background: transparent;
  1776. opacity: 0.25;
  1777. }
  1778. #\\answers a.deleted-answer:hover {
  1779. opacity: 1.0;
  1780. }
  1781. #\\answers a:hover:not(.SEpreviewed) {
  1782. text-decoration: underline;
  1783. text-decoration-skip: ink;
  1784. }
  1785. #\\answers a.SEpreviewed {
  1786. background-color: ${colors.answer.fore};
  1787. color: ${colors.answer.foreInv};
  1788. outline: 4px solid ${colors.answer.fore};
  1789. }
  1790. #\\answers a::after {
  1791. white-space: nowrap;
  1792. overflow: hidden;
  1793. text-overflow: ellipsis;
  1794. max-width: 40px;
  1795. position: absolute;
  1796. content: attr(title);
  1797. top: 44px;
  1798. left: 0;
  1799. font: normal .75rem/1.0 sans-serif;
  1800. opacity: .7;
  1801. }
  1802. #\\answers a:only-child::after {
  1803. max-width: calc(${WIDTH}px - 10em);
  1804. }
  1805. #\\answers a:hover::after {
  1806. opacity: 1;
  1807. }
  1808. .\\accepted::before {
  1809. content: "✔";
  1810. position: absolute;
  1811. display: block;
  1812. top: 1.3ex;
  1813. right: -0.7ex;
  1814. font-size: 32px;
  1815. color: #4bff2c;
  1816. text-shadow: 1px 2px 2px rgba(0,0,0,0.5);
  1817. }
  1818.  
  1819. @-webkit-keyframes highlight {
  1820. from {background: #ffcf78}
  1821. to {background: none}
  1822. }
  1823. `,
  1824.  
  1825. ...Object.keys(colors).map(s => `
  1826. #\\title {
  1827. background-color: ${colors[s].back}5e;
  1828. color: ${colors[s].fore};
  1829. }
  1830. #\\meta {
  1831. color: ${colors[s].fore};
  1832. }
  1833. #\\meta b {
  1834. color: ${colors[s].foreInv};
  1835. background: ${colors[s].fore};
  1836. }
  1837. #\\close {
  1838. color: ${colors[s].fore};
  1839. }
  1840. #\\parts::-webkit-scrollbar {
  1841. background-color: ${colors[s].back}19;
  1842. }
  1843. #\\parts::-webkit-scrollbar-thumb {
  1844. background-color: ${colors[s].back}32;
  1845. }
  1846. #\\parts::-webkit-scrollbar-thumb:hover {
  1847. background-color: ${colors[s].back}4b;
  1848. }
  1849. #\\parts::-webkit-scrollbar-thumb:active {
  1850. background-color: ${colors[s].back}c0;
  1851. }
  1852. `
  1853. // language=JS
  1854. .replace(/#\\/g, `[\\type$="${s}"] $&`)
  1855. ),
  1856.  
  1857. ...['deleted', 'closed'].map(s => /* language=CSS */ `
  1858. #\\answers {
  1859. border-top-color: ${colors[s].back}5e;
  1860. background-color: ${colors[s].back}5e;
  1861. color: ${colors[s].fore};
  1862. }
  1863. #\\answers a.SEpreviewed {
  1864. background-color: ${colors[s].fore};
  1865. color: ${colors[s].foreInv};
  1866. }
  1867. #\\answers a.SEpreviewed:after {
  1868. border-color: ${colors[s].fore};
  1869. }
  1870. `
  1871. // language=JS
  1872. .replace(/#\\/g, `[\\type$="${s}"] $&`)
  1873. ),
  1874.  
  1875. GM_getResourceText(`HL-style${isDark ? '-dark' : ''}`),
  1876. ].join('\n').replace(/\\/g, `${ID}-`);
  1877. }
  1878.  
  1879. static applyRemScale(id, css) {
  1880. const el = pv.styles.get(id);
  1881. if (pv.remScale && pv.remScale !== 1 && !pv.stylesScaled.has(id)) {
  1882. css = (css || el.textContent).replace(/([:\s])((?:\d*\.?)?\d+)(?=rem([;}\s]|\/\*))/gi,
  1883. (_, prev, size) => prev + (pv.remScale * size));
  1884. pv.stylesScaled.add(id);
  1885. }
  1886. el.textContent = css;
  1887. }
  1888. }
  1889.  
  1890. function $(selector, node = pv.shadow) {
  1891. return node && node.querySelector(selector);
  1892. }
  1893.  
  1894. Object.assign($, {
  1895.  
  1896. all(selector, node = pv.shadow) {
  1897. return node ? [...node.querySelectorAll(selector)] : [];
  1898. },
  1899.  
  1900. on(eventName, node, fn, options) {
  1901. return node.addEventListener(eventName, fn, options);
  1902. },
  1903.  
  1904. off(eventName, node, fn, options) {
  1905. return node.removeEventListener(eventName, fn, options);
  1906. },
  1907.  
  1908. remove(selector, node = pv.shadow) {
  1909. for (const el of node.querySelectorAll(selector))
  1910. el.remove();
  1911. },
  1912.  
  1913. text(selector, node = pv.shadow) {
  1914. const el = typeof selector === 'string' ?
  1915. node && node.querySelector(selector) :
  1916. selector;
  1917. return el ? el.textContent.trim() : '';
  1918. },
  1919.  
  1920. create(
  1921. selector,
  1922. opts = {},
  1923. children = opts.children ||
  1924. (typeof opts !== 'object' || Util.isIterable(opts)) && opts
  1925. ) {
  1926. const EOL = selector.length;
  1927. const idStart = (selector.indexOf('#') + 1 || EOL + 1) - 1;
  1928. const clsStart = (selector.indexOf('.', idStart < EOL ? idStart : 0) + 1 || EOL + 1) - 1;
  1929. const tagEnd = Math.min(idStart, clsStart);
  1930. const tag = (tagEnd < EOL ? selector.slice(0, tagEnd) : selector) || opts.tag || 'div';
  1931. const id = idStart < EOL && selector.slice(idStart + 1, clsStart) || opts.id || '';
  1932. const cls = clsStart < EOL && selector.slice(clsStart + 1).replace(/\./g, ' ') ||
  1933. opts.className ||
  1934. '';
  1935. const el = id && pv.shadow && pv.shadow.getElementById(id) ||
  1936. document.createElement(tag);
  1937. if (el.id !== id)
  1938. el.id = id;
  1939. if (el.className !== cls)
  1940. el.className = cls;
  1941. const hasOwnProperty = Object.hasOwnProperty;
  1942. for (const key in opts) {
  1943. if (!hasOwnProperty.call(opts, key))
  1944. continue;
  1945. const value = opts[key];
  1946. switch (key) {
  1947. case 'tag':
  1948. case 'id':
  1949. case 'className':
  1950. case 'children':
  1951. break;
  1952. case 'dataset': {
  1953. const dataset = el.dataset;
  1954. for (const k in value) {
  1955. if (hasOwnProperty.call(value, k)) {
  1956. const v = value[k];
  1957. if (dataset[k] !== v)
  1958. dataset[k] = v;
  1959. }
  1960. }
  1961. break;
  1962. }
  1963. case 'attributes': {
  1964. for (const k in value) {
  1965. if (hasOwnProperty.call(value, k)) {
  1966. const v = value[k];
  1967. if (el.getAttribute(k) !== v)
  1968. el.setAttribute(k, v);
  1969. }
  1970. }
  1971. break;
  1972. }
  1973. default:
  1974. if (el[key] !== value)
  1975. el[key] = value;
  1976. }
  1977. }
  1978. if (children) {
  1979. if (!hasOwnProperty.call(opts, 'textContent'))
  1980. el.textContent = '';
  1981. $.appendChildren(el, children);
  1982. }
  1983. let before, after, parent;
  1984. if ((before = opts.before) && before !== el.nextSibling && before !== el)
  1985. before.insertAdjacentElement('beforebegin', el);
  1986. else if ((after = opts.after) && after !== el.previousSibling && after !== el)
  1987. after.insertAdjacentElement('afterend', el);
  1988. else if ((parent = opts.parent) && parent !== el.parentNode)
  1989. parent.appendChild(el);
  1990. return el;
  1991. },
  1992.  
  1993. appendChild(parent, child, shouldClone = true) {
  1994. if (!child)
  1995. return;
  1996. if (child.nodeType)
  1997. return parent.appendChild(shouldClone ? document.importNode(child, true) : child);
  1998. if (Util.isIterable(child))
  1999. return $.appendChildren(parent, child, shouldClone);
  2000. else
  2001. return parent.appendChild(document.createTextNode(child));
  2002. },
  2003.  
  2004. appendChildren(newParent, children) {
  2005. if (!Util.isIterable(children))
  2006. return $.appendChild(newParent, children);
  2007. const fragment = document.createDocumentFragment();
  2008. for (const el of children)
  2009. $.appendChild(fragment, el);
  2010. return newParent.appendChild(fragment);
  2011. },
  2012.  
  2013. setStyle(el, ...props) {
  2014. const style = el.style;
  2015. const s0 = style.cssText;
  2016. let s = s0;
  2017.  
  2018. for (const p of props) {
  2019. if (!p)
  2020. continue;
  2021.  
  2022. const [name, value, important = true] = p;
  2023. const rValue = value + (important && value ? ' !important' : '');
  2024. const rx = new RegExp(`(^|[\\s;])${name}(\\s*:\\s*)([^;]*?)(\\s*(?:;|$))`, 'i');
  2025. const m = rx.exec(s);
  2026.  
  2027. if (!m && value) {
  2028. const rule = name + ': ' + rValue;
  2029. s += !s || s.endsWith(';') ? rule : '; ' + rule;
  2030. continue;
  2031. }
  2032.  
  2033. if (!m && !value)
  2034. continue;
  2035.  
  2036. const [, sep1, sep2, oldValue, sep3] = m;
  2037. if (value !== oldValue) {
  2038. s = s.slice(0, m.index) +
  2039. sep1 + (rValue ? name + sep2 + rValue + sep3 : '') +
  2040. s.slice(m.index + m[0].length);
  2041. }
  2042. }
  2043.  
  2044. if (s !== s0)
  2045. style.cssText = s;
  2046. },
  2047. });

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址