SE Preview on hover

Shows preview of the linked questions/answers on hover

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

QingJ © 2025

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