SE Preview on hover

Shows preview of the linked questions/answers on hover

目前为 2021-07-09 提交的版本。查看 最新版本

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