// ==UserScript== // @name 4chan-neXT (beta) // @version 1.2.6.5.20260725072115 // @minGMVer 1.14 // @minFFVer 78 // @namespace 4chan-neXT-beta // @description 4chan-neXT is a script that adds various features to anonymous imageboards. // @license MIT; https://github.com/cercos/4chan-next/blob/project-neXT/LICENSE // @include https://boards.4chan.org/* // @include https://sys.4chan.org/* // @include https://4chan.org/* // @include https://www.4chan.org/* // @include https://i.4cdn.org/* // @include https://is.4chan.org/* // @include https://is2.4chan.org/* // @include https://erischan.org/* // @include https://www.erischan.org/* // @include https://fufufu.moe/* // @include https://kakashinenpo.com/* // @include https://www.kakashinenpo.com/* // @include https://kissu.moe/* // @include https://www.kissu.moe/* // @include https://original.kissu.moe/* // @include https://lainchan.org/* // @include https://www.lainchan.org/* // @include https://merorin.com/* // @include https://ota-ch.com/* // @include https://www.ota-ch.com/* // @include https://ponyville.us/* // @include https://www.ponyville.us/* // @include https://smuglo.li/* // @include https://notso.smuglo.li/* // @include https://smugloli.net/* // @include https://smug.nepu.moe/* // @include https://sportschan.org/* // @include https://www.sportschan.org/* // @include https://sushigirl.us/* // @include https://www.sushigirl.us/* // @include https://tvch.moe/* // @match https://boards.4chan.org/* // @match https://sys.4chan.org/* // @match https://4chan.org/* // @match https://www.4chan.org/* // @match https://i.4cdn.org/* // @match https://is.4chan.org/* // @match https://is2.4chan.org/* // @match https://*.4chan.org/* // @match https://*.4cdn.org/* // @match https://erischan.org/* // @match https://www.erischan.org/* // @match https://fufufu.moe/* // @match https://kakashinenpo.com/* // @match https://www.kakashinenpo.com/* // @match https://kissu.moe/* // @match https://www.kissu.moe/* // @match https://original.kissu.moe/* // @match https://lainchan.org/* // @match https://www.lainchan.org/* // @match https://merorin.com/* // @match https://ota-ch.com/* // @match https://www.ota-ch.com/* // @match https://ponyville.us/* // @match https://www.ponyville.us/* // @match https://smuglo.li/* // @match https://notso.smuglo.li/* // @match https://smugloli.net/* // @match https://smug.nepu.moe/* // @match https://sportschan.org/* // @match https://www.sportschan.org/* // @match https://sushigirl.us/* // @match https://www.sushigirl.us/* // @match https://tvch.moe/* // @exclude https://www.4chan.org/advertise // @exclude https://www.4chan.org/advertise?* // @exclude https://www.4chan.org/donate // @exclude https://www.4chan.org/donate?* // @connect 4chan.org // @connect 4channel.org // @connect 4cdn.org // @connect 4chenz.github.io // @connect archive.4plebs.org // @connect warosu.org // @connect desuarchive.org // @connect boards.fireden.net // @connect arch.b4k.dev // @connect archived.moe // @connect thebarchive.com // @connect archiveofsins.com // @connect archive.palanq.win // @connect eientei.xyz // @connect api.clyp.it // @connect api.dailymotion.com // @connect api.github.com // @connect soundcloud.com // @connect api.streamable.com // @connect vimeo.com // @connect www.youtube.com // @connect * // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_addValueChangeListener // @grant GM_openInTab // @grant GM_xmlhttpRequest // @grant GM_addElement // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM.listValues // @grant GM.openInTab // @grant GM.xmlHttpRequest // @run-at document-start // @updateURL https://raw.githubusercontent.com/cercos/4chan-neXT/beta/builds/4chan-neXT.meta.js // @downloadURL https://raw.githubusercontent.com/cercos/4chan-neXT/beta/builds/4chan-neXT.user.js // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAANAElEQVRo3t2ae4xcV33HP79zHzM7+5r1Pv1c2+A4sWMSNSGBkkKCSEIJNKEKSlNQVWhpFYVKLWqFVKl/tFJbRagCVapKVQiIgAgQKaJNQwHTOKmbd5yHYzv2JnF2ba/3PTuPnde99/z6x53ZmdmdWa/T/tMe6ereOXvuOb/37/v73YX/j2Pn3lEAjJH/1X3FCCKb3TNeV1/f6T233aTnrk6b1Z02N6IN/6paf3IuvZXW31HAijS/fgkGCqVSbQu5F+FuVCNoFsGanWLxLKD6F8BsZ6EKQD/CX6HsRNW2CkhaNzY4qvwrwoNW22/ZloGwGgtShUMi3NXd5+L5Zg3ZCghqlcJyRBTqDPDXMTnSKvEa7xo/uigfdzy5oqffRdqYqQgEVWUlG6Jwjk7i78SAjaKYCFXresKd94/p+IEu1K7RgIgUsxHf/5sLLJyv+ojcBbwNPAEU1mybQLlZYTeW7pGdPvd+ZbsmUlIThawKyBg490aZh786LUHZWqTGVRsm2jIQWUtdklbh/GSZ/u0eu/d1tWpABC8hiAHQAZCvI0yifAQo0Op4KYQHUK4BVccRTY+6JHucmpk31k69WeL8VJmW6cvRQLOtqMLM+QqjMwnG39u1boHnG95/W5rsQiAnnyuQuRi4CMOo5hHJoghoGpFBrHrDOxPsv76HgTEPx5UmrTbG4myVmXMV0WbDF9a5XkcGFG1xLWPikCprTBtV9buM3HzvIFGgOn+hKpmL1RFUfojIRYTPIiRR+S7KALBz654En7xvJLb9NgRJ7Szj0JB/B+I7MrAac2u3Us6SnQtYnAvwfaGnfzUKCoDa2AjGdie0XIi8xYvB3pVstAXlAEIXcDDV73QPbfMZ2ZWIF6siayhbyUdUSpbl2YBS1jYJqjMH7TWg1IOMsZEy9UpJcjOBZjIBW3cluOGWNMa02pJx4LbfHcaGqj/+u4ty/KlcHyIPERPbtedQinv+bKs6bt1nWi1WUM68VuDsyRKnnsxTyERio5pORC9PA6Z2gsCzCt+ygd5SLeveoGw1rCrWxkFBmtKjgHoJQT0hmTL4XcYYRwYBbER9DhGJTbSJeLXxPQyUStlSLavYQN9G5AhwtB5/23HRPssawPUgCBDFqMgjTkI+3Z12SI96eujmXvqHPA5e3yOuZ2hQFN+WLgZSzLUm5a5eR7ds89cdGASWEy/kyS1GvH4kx9JMICvLETbUR43lbota+rsgW9q8BgaG0mTmlptt5M2oqq/kZsIdQUWHLk5VNAhUWiNIPTfA4HaPoR1+Sx5TRdQ2RKjEPqAWFmYD5i9UZeZshUreZjBMAhNIHDgkX0YSHlElWEdrW0xSXinz3qt3szS3jBFRRf8L5HsIV6IcKmYjyc4ERAL5bMjAkCeOK836lFU/UlTrcaSepQWqZcuplwtMHFvh+BN5FiarUspZVPklym8iHHZ9t6qAjSwa2XakdsgD6+VaxDNFQrsQVnUxcy7oynpBqnfMZWxvkr0HUngJQ0dPW9VNA+sEVWVqosyFM2WZPl0GpYJIAZgzSsaCDdpIfFMaAFiqmRCA4zr0GqUS8SrC9xGGgGtWliOionLopl71ky3AtUkXdSNqJBcxUMpFPPXwErNvVySsKsDTwD3A46B5FPYe3M3ASJrM/HInMi+tgfrIVRURLqrIRVQXVKG4GJHvDlG7AdxqRQmNaavk5kNKmSgOGpAXq6+rERWgf7APdxNI3lxyBTC4rX/1UZQrgEERcDzB800T5NFW6a/qYHW+STOC5wuOv+o7vRhztSBj+xSWF3NYUaxsaJYbs2gcAxonQlVF4I8V/hyrqVS/233n/aMMbfPZ+p4kjtd8kjbwM1JPuXUeFYGwYpl+q8L8VEV+8o+zVFZsBSMF0IfwnT+RoLPjNo/NmlBaRFxVHTOODG/bl2R4h69XXNdNzxZPbNReSnWsX7Otluzl+obdh7pID7u6//pulmaDxPRblYSN2CqBHQPKrusuIzC+fydvvX627RkdnXjfod0szmZQVQfhAeBvUT7oJ033b31lm/7qXVvwk460Gn4NfxiRmbMVjvxokYljK9I/7NGTdltNCEWt4HcZDt7Uy9h4kteezEkU6E6Qe0B3Yvm5tVYHRhp56fI0sAqm2CFG9rlJoWfAJZEyuJ5g7bqVoIqqMH++ylM/XkIE9lydYnQ8oS0AX6RWUgiuJyRShu60q1bD/rCq/WrZoap1Y+w4OmpgcDRdD6UGkc/4Xeaq/Td1662/PcyOfV1186hvXwuWQnY+5LFvzMrJpwvkFkMQWJ4NmDi2IoNbffqG3PXpQiHV6zA87lNVJTsTSljRkwKPIOiWd62B5iGQ6DH0D7t4flyptRIRA6LySsTxo3lKuSjESA5w3nm92CdG5Npb+mT7vqSuj6yK6wv9wy7JHtOCVgU62v+GDEwcf6etSbUL9iKQmQs5/NA82YVQgrKCyATwBWAcI99E6Dnyo0VOPFOQj31uSAdGvLV7ieolYub/SAPUqiUHjFNDZ8QAxwhUipbjR/NSzkchIkWEGSPyqqoWFBZUCd45Xuyenaz6H757C8aNOxp1OYsRNWZtA2uDUuxyGYgCZeZMRZ57bJnefreWHRqVW34xFBsCIm8BfyBwEagAbwt8CkiqyNeiUG96/vFl6R1w6zBcQBEjZJdCnT5TrkMLEYlP2bZrhOnJuctVDnX6HEQewYgiEkH9IgKxTVdUW/MykEYETNNFEoz8BCMWpApU43vTs9QuIxYjD5MEqZV+dGgtbhik6pFGlV9DGAfWB85WLQvoEsphMbJFYS/1JCY4wIeB7dBSlLWjRIApQY4izGpk3xSRWvtlEwx4CZegEiKOaftSCwMdh35RRL7uemJVIQwVIATUOILjQBTWuxBtJSxhoEm1+h2s3tfplEv5wA5giFUo3NKniUQ4g9VIRfYDXtN7IZa96VEvdef9oyzPBTz2T3OEQVycX/PRXm78RJr/fGSJHVd2sedQCo1a8YaN4GffmefcqVJyIwI7dCVqmylfBn4PS7i+oUte4U8RKaJ8DdVhmnM3JP2kMH6gS3dckZRzb5R46Rc5dlyR5Ibb07r3mm5ePZJjbLcv73lfSlVrJqtKZOOgkep15F1FoTiUGYBu1zN919/aR3o4jtsikM+EPPfT5S1hRR8ENNXn9N74ibQkuw0IXJgo89qTOTKzIY/+/Yzc9aUxbv/8iHb1uBz4YA97rk3x02/NcfKZAlOnyzzz2DI793fJrZ8b1nwm5PFvzlHIRjL9ZhlETKe2YmcNsNoYUseDG+5I6/hVKWykqwx4vsjLT+R6VeEDd6T5wKcGNL8U4vqGfb/SLeUVy5mXVjj94grvnChx8EM93PGHIziuMPtOhVPPFMjOh2Tnw/hEGyfJatkycWxFCkvhLMJJRI43MNR6RtoWNE0ds1rnDWykHH10iR88MC2VkuXjvz+sI7t8Rnb5fPSzQ2RmA374wDT/8g8zMrjN5/2/3o8IVFYszz6WIagoplb4Hz+aZ3ayEp9ea0HF0TJ2tRqUeFqUT4rK16FWm7QZl6rIWliePFHi1SM5SrmmaKpgI9WxPQl+5y93cveXt2p32qlLgt4tLh+6awteQrBhDEivvaWPPYdSayqERhu0Fo8sUAENL9sHYhMysCbMihGMI432SF1TIhSzIW88VyAKFTFw7o0S/UMen/6jUd13XbdUipaJl4rsOtDF4Haf37hvlH/75zkmXio0dZLXhFJpJIz4m8UmGWgIv6kybKsaRSSO6fmlkGOHlwkDlWrJkl0IufKGHva+rxsROHY4y79/e16u+1hab//CMNv2Jdl9dReTp0pSLTZKOm0+rxY0dh8c5+yJyc0z0OQDCrIK4KSGfowDjhNrJDMdcPKZAlfd2MMXvzqOGHTqRFF+8d0F7vzSKKl+h+NHchz+3oKUC5YXfr5Mqs9w2+dH+MhnBpk6VdLTzxdAakCxqY3fSXib1EDMig3hzAsrLE2HLM0ERJFy+vkVZicrFDIhCxcq/OzBeamW7GrneX6qSjEfcfrFFU2myrx5bIVCJgQjVItWzh4v6iv/kUUjKCzHLej8YsirR3KsLEcEFb0EyLkUV74Tm49jvoERdTxjHd9Y4xlrXGMdz1jHM2rcxlWbs45nNP6baP25/rtp7er6eE7UeEYdP57DMYqRR0TEERH2HBy/PA1IvZGmcd/nlnsGGdru0/goqq2fcVr799qyioYwtQ1io1bLSa1nml8K+eUPFijl4w+Nina0/84m1DBCJwqVk8/mJdntNCXETRdO0n5K2y8VCMqWasUCOHUn3ui0DT/yCbxorQ6fP12KWmm57Mrv8vg1OAhPC6hu8I24g4QAz5AKfEpSNYA0ILUgohvt12GshQKyZr79S46DRYQo7Nyh6xhGi1RZrVqbep+rv1t6PM3HduJOW9dqE3Dt+A8g8i6E9X9s/DcUlzESN8umeQAAAABJRU5ErkJggg== // @license MIT // ==/UserScript== /* * 4chan-neXT * * Licensed under the MIT license. * https://github.com/TuxedoTako/4chan-xt/blob/project-XT/LICENSE * * 4chan X Copyright © 2009-2023 ccd0 * https://github.com/ccd0/4chan-x * Appchan X Copyright © 2013-2016 Zixaphir * http://zixaphir.github.io/appchan-x/ * 4chan x Copyright © 2009-2011 James Campos * https://github.com/aeosynth/4chan-x * 4chan x Copyright © 2012-2014 Nicolas Stepien * https://4chan-x.just-believe.in/ * 4chan x Copyright © 2013-2014 Jordan Bates * http://seaweedchan.github.io/4chan-x/ * 4chan x Copyright © 2012-2013 ihavenoface * http://ihavenoface.github.io/4chan-x/ * 4chan SS Copyright © 2011-2013 Ahodesuka * https://github.com/ahodesuka/4chan-Style-Script/ * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * Contributors: * aeosynth * mayhemydg * noface * !K.WeEabo0o * blaise * that4chanwolf * desuwa * seaweed * e000 * ahodesuka * Shou * ferongr * xat * Ongpot * thisisanon * Anonymous * Seiba * herpaderpderp * WakiMiko * btmcsweeney * AppleBloom * detharonil * TuxedoTako * * All the people who've taken the time to write bug reports. * * Thank you. */ /* * Contains data from external sources: * * src/Monitoring/ThreadUpdater/beep.wav from http://freesound.org/people/pierrecartoons1979/sounds/90112/ * cc-by-nc-3.0 * * Font Awesome (https://fontawesome.com) * license: CC BY 4.0 (https://fontawesome.com/license/free#icons) * * Icons used to identify various websites are property of the respective websites. */ var FourchanNeXT = (function () { 'use strict'; var version = { "version": "1.2.6.5", "date": "2026-07-25T00:00:00Z" } ; var meta = { "name": "4chan-neXT", "path": "4chan-neXT", "fork": "cercos", "page": "https://4chan-next.com", "downloads": "https://github.com/cercos/4chan-next/releases", "updateBranch": "project-neXT", "oldVersions": "https://raw.githubusercontent.com/ccd0/4chan-x/", "faq": "https://github.com/cercos/4chan-next/wiki/Frequently-Asked-Questions", "upstreamFaq": "https://github.com/ccd0/4chan-x/wiki/Frequently-Asked-Questions", "captchaFAQ": "https://github.com/ccd0/4chan-x/wiki/Captcha-FAQ", "cssGuide": "https://github.com/cercos/4chan-next/blob/project-neXT/docs/styling-guide.md", "userGuide": "https://github.com/cercos/4chan-next/blob/project-neXT/docs/user-guide.md", "license": "https://github.com/cercos/4chan-next/blob/project-neXT/LICENSE", "changelog": "https://github.com/cercos/4chan-next/blob/project-neXT/CHANGELOG.md", "issues": "https://github.com/cercos/4chan-next/issues", "newIssue": "https://github.com/cercos/4chan-next/issues", "newIssueMaxLength": 8181, "alternatives": "https://www.4chan-x.net/4chan_alternatives.html", "appid": "lacclbnghgdicfifcamcmcnilckjamag", "appidGecko": "4chan-x@4chan-x.net", "recaptchaKey": "6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc", "min": { "chrome": "90", "firefox": "78", "greasemonkey": "1.14" } }; const Conf = Object.create(null); const g = { VERSION: version.version, VERSION_DATE: new Date(version.date), NAMESPACE: meta.name, sites: Object.create(null), boards: Object.create(null) }; const E = (function () { const str = { '&': '&', "'": ''', '"': '"', '<': '<', '>': '>' }; const regex = /[&"'<>]/g; const fn = function (x) { return str[x]; }; const output = function (text) { return text.toString().replace(regex, fn); }; output.cat = function (templates) { let html = ''; for (let i = 0; i < templates.length; i++) { html += templates[i].innerHTML; } return html; }; return output; })(); const d = document; const doc = d.documentElement; const c = console; const docSet = function () { return doc; }; class Callbacks { static initClass() { this.Post = new Callbacks('Post'); this.Thread = new Callbacks('Thread'); this.CatalogThread = new Callbacks('Catalog Thread'); this.CatalogThreadNative = new Callbacks('Catalog Thread'); } constructor(type) { this.type = type; this.keys = []; } push({ name, cb }) { if (!this[name]) { this.keys.push(name); } return this[name] = cb; } execute(node, keys = this.keys, force = false) { let errors; if (node.callbacksExecuted && !force) { return; } node.callbacksExecuted = true; for (var name of keys) { try { this[name]?.call(node); } catch (err) { if (!errors) { errors = []; } errors.push({ message: ['"', name, '" crashed on node ', this.type, ' No.', node.ID, ' (', node.board, ').'].join(''), error: err, html: node.nodes?.root?.outerHTML }); } } if (errors) { return Main.handleErrors(errors); } } } Callbacks.initClass(); var userCss = `div.boardTitle{font-weight:400 !important}:root.yotsuba div.boardTitle,:root.yotsuba-b div.boardTitle,:root.photon div.boardTitle,:root.tomorrow div.boardTitle{font-family:sans-serif !important}:root.yotsuba div.boardTitle{text-shadow:1px 1px 1px rgba(100,0,0,0.6)}:root.yotsuba-b div.boardTitle{text-shadow:1px 1px 1px rgba(105,10,15,0.6)}:root.photon div.boardTitle{text-shadow:1px 1px 1px rgba(0,74,153,0.6)}:root.tomorrow div.boardTitle{text-shadow:1px 1px 1px rgba(167,170,168,0.6)}`; var banners = ["0.jpg", "1.jpg", "2.jpg", "4.jpg", "6.jpg", "7.jpg", "8.jpg", "9.jpg", "10.jpg", "11.jpg", "12.jpg", "13.jpg", "14.jpg", "16.jpg", "17.jpg", "18.jpg", "19.jpg", "20.jpg", "21.jpg", "22.jpg", "24.jpg", "25.jpg", "26.jpg", "28.jpg", "29.jpg", "33.jpg", "38.jpg", "39.jpg", "43.jpg", "44.jpg", "45.jpg", "46.jpg", "47.jpg", "52.jpg", "54.jpg", "57.jpg", "59.jpg", "60.jpg", "61.jpg", "64.jpg", "66.jpg", "67.jpg", "69.jpg", "71.jpg", "72.jpg", "76.jpg", "77.jpg", "81.jpg", "82.jpg", "83.jpg", "84.jpg", "88.jpg", "90.jpg", "91.jpg", "96.jpg", "98.jpg", "99.jpg", "100.jpg", "104.jpg", "106.jpg", "116.jpg", "119.jpg", "137.jpg", "140.jpg", "148.jpg", "149.jpg", "150.jpg", "154.jpg", "156.jpg", "157.jpg", "158.jpg", "159.jpg", "161.jpg", "162.jpg", "164.jpg", "165.jpg", "166.jpg", "167.jpg", "168.jpg", "169.jpg", "170.jpg", "171.jpg", "172.jpg", "173.jpg", "174.jpg", "175.jpg", "176.jpg", "178.jpg", "179.jpg", "180.jpg", "181.jpg", "182.jpg", "183.jpg", "186.jpg", "189.jpg", "190.jpg", "192.jpg", "193.jpg", "194.jpg", "197.jpg", "198.jpg", "200.jpg", "201.jpg", "202.jpg", "203.jpg", "205.jpg", "206.jpg", "207.jpg", "208.jpg", "210.jpg", "213.jpg", "214.jpg", "215.jpg", "216.jpg", "218.jpg", "219.jpg", "220.jpg", "221.jpg", "222.jpg", "223.jpg", "224.jpg", "227.jpg", "0.png", "1.png", "2.png", "3.png", "5.png", "6.png", "9.png", "10.png", "11.png", "12.png", "14.png", "16.png", "19.png", "20.png", "21.png", "22.png", "23.png", "24.png", "26.png", "27.png", "28.png", "29.png", "30.png", "31.png", "32.png", "33.png", "34.png", "37.png", "39.png", "40.png", "41.png", "42.png", "43.png", "44.png", "45.png", "48.png", "49.png", "50.png", "51.png", "52.png", "53.png", "57.png", "58.png", "59.png", "64.png", "66.png", "67.png", "68.png", "69.png", "70.png", "71.png", "72.png", "76.png", "78.png", "79.png", "81.png", "82.png", "85.png", "86.png", "87.png", "89.png", "95.png", "98.png", "100.png", "101.png", "102.png", "105.png", "106.png", "107.png", "109.png", "110.png", "111.png", "112.png", "113.png", "114.png", "115.png", "116.png", "118.png", "119.png", "120.png", "121.png", "122.png", "123.png", "126.png", "128.png", "130.png", "134.png", "136.png", "138.png", "139.png", "140.png", "142.png", "145.png", "146.png", "149.png", "150.png", "151.png", "152.png", "153.png", "154.png", "155.png", "156.png", "157.png", "158.png", "159.png", "160.png", "163.png", "164.png", "165.png", "166.png", "167.png", "168.png", "169.png", "170.png", "171.png", "172.png", "173.png", "174.png", "178.png", "179.png", "180.png", "181.png", "182.png", "184.png", "186.png", "188.png", "190.png", "192.png", "193.png", "194.png", "195.png", "196.png", "197.png", "198.png", "200.png", "202.png", "203.png", "205.png", "206.png", "207.png", "209.png", "212.png", "213.png", "214.png", "216.png", "217.png", "218.png", "219.png", "220.png", "221.png", "222.png", "223.png", "224.png", "225.png", "226.png", "229.png", "231.png", "232.png", "233.png", "234.png", "235.png", "237.png", "238.png", "239.png", "240.png", "241.png", "242.png", "244.png", "245.png", "246.png", "247.png", "248.png", "249.png", "250.png", "253.png", "254.png", "255.png", "256.png", "257.png", "258.png", "259.png", "260.png", "262.png", "268.png", "0.gif", "1.gif", "2.gif", "3.gif", "4.gif", "5.gif", "6.gif", "7.gif", "8.gif", "9.gif", "10.gif", "12.gif", "13.gif", "14.gif", "15.gif", "16.gif", "18.gif", "19.gif", "20.gif", "21.gif", "22.gif", "23.gif", "24.gif", "28.gif", "29.gif", "30.gif", "33.gif", "34.gif", "35.gif", "36.gif", "37.gif", "39.gif", "40.gif", "42.gif", "44.gif", "45.gif", "46.gif", "48.gif", "50.gif", "52.gif", "54.gif", "55.gif", "57.gif", "58.gif", "59.gif", "60.gif", "61.gif", "63.gif", "64.gif", "66.gif", "67.gif", "68.gif", "69.gif", "70.gif", "72.gif", "73.gif", "75.gif", "76.gif", "77.gif", "78.gif", "80.gif", "81.gif", "82.gif", "83.gif", "86.gif", "87.gif", "88.gif", "92.gif", "93.gif", "94.gif", "95.gif", "96.gif", "97.gif", "98.gif", "99.gif", "100.gif", "101.gif", "102.gif", "103.gif", "104.gif", "105.gif", "106.gif", "108.gif", "109.gif", "110.gif", "111.gif", "112.gif", "113.gif", "115.gif", "116.gif", "117.gif", "118.gif", "119.gif", "120.gif", "122.gif", "123.gif", "124.gif", "127.gif", "129.gif", "130.gif", "131.gif", "134.gif", "135.gif", "136.gif", "138.gif", "139.gif", "141.gif", "144.gif", "146.gif", "148.gif", "149.gif", "153.gif", "154.gif", "155.gif", "157.gif", "158.gif", "159.gif", "160.gif", "161.gif", "162.gif", "164.gif", "166.gif", "167.gif", "168.gif", "169.gif", "170.gif", "171.gif", "172.gif", "173.gif", "174.gif", "175.gif", "176.gif", "177.gif", "178.gif", "181.gif", "182.gif", "183.gif", "185.gif", "186.gif", "187.gif", "188.gif", "189.gif", "190.gif", "191.gif", "192.gif", "193.gif", "195.gif", "196.gif", "197.gif", "200.gif", "201.gif", "202.gif", "203.gif", "204.gif", "205.gif", "206.gif", "207.gif", "208.gif", "209.gif", "210.gif", "211.gif", "212.gif", "213.gif", "214.gif", "215.gif", "216.gif", "217.gif", "219.gif", "220.gif", "221.gif", "222.gif", "224.gif", "225.gif", "226.gif", "227.gif", "228.gif", "230.gif", "232.gif", "233.gif", "234.gif", "235.gif", "238.gif", "240.gif", "241.gif", "243.gif", "244.gif", "245.gif", "246.gif", "247.gif", "249.gif", "250.gif", "251.gif", "253.gif"]; const Config = { main: { 'Miscellaneous': { 'JSON Index': [ true, 'Replace the original board index with one supporting searching, sorting, infinite scrolling, and a catalog mode.' ], [`Use ${meta.name} Catalog`]: [ true, `Link to ${meta.name}'s catalog instead of the native 4chan one.`, 1 ], 'Index Refresh Notifications': [ false, 'Show a notice at the top of the page when the index is refreshed.', 1 ], 'Follow Cursor': [ true, 'Image Hover and Quote Preview move with the mouse cursor.' ], 'Settings Descriptions as Tooltips': [ false, 'Show setting descriptions as browser tooltips instead of inline text.' ], 'Open Threads in New Tab': [ false, `Make links to threads in the index / ${meta.name} catalog open in a new tab.` ], 'External Catalog': [ false, 'Link to external catalog instead of the internal one.' ], 'Catalog Links': [ false, 'Add toggle link in header menu to turn Navigation links into links to each board\'s catalog.' ], 'Announcement Hiding': [ true, 'Add button to hide 4chan announcements.' ], 'Desktop Notifications': [ true, `Enables desktop notifications across various ${meta.name} features.` ], '404 Redirect': [ true, 'Redirect dead threads and images to the archives.' ], 'Archive Report': [ true, 'Enable reporting posts to supported archives.' ], 'Exempt Archives from Encryption': [ false, 'Permit loading content from, and warningless redirects to, HTTP-only archives from HTTPS pages.' ], 'Keybinds': [ true, 'Bind actions to keyboard shortcuts.' ], 'Time Formatting': [ true, 'Localize and format timestamps. Has more options on the "Advanced" tab.' ], 'Relative Post Dates': [ false, 'Display dates like "3 minutes ago". Tooltip shows the timestamp.' ], 'Relative Date Title': [ false, 'Show Relative Post Date only when hovering over dates.', 1 ], 'Comment Expansion': [ true, 'Expand comments that are too long to display on the index. Not applicable with JSON Index.' ], 'File Info Formatting': [ true, 'Reformat the file information.' ], 'Thread Expansion': [ true, 'Add buttons to expand threads.' ], 'Index Navigation': [ false, 'Add buttons to navigate between threads.' ], 'Reply Navigation': [ false, 'Add buttons to navigate to top / bottom of thread.' ], 'Unique ID and Capcode Navigation': [ false, 'Add buttons to navigate to posts having the same unique ID or capcode.' ], 'Custom Board Titles': [ true, 'Allow editing of the board title and subtitle by ctrl/\u2318+clicking them.' ], 'Persistent Custom Board Titles': [ false, 'Force custom board titles to be persistent, even if the board titles are updated.', 1 ], 'Show Updated Notifications': [ true, `Show notifications when ${meta.name} is successfully updated.` ], 'Color User IDs': [ true, 'Assign unique colors to user IDs on boards that use them' ], 'Count Posts by ID': [ true, 'Display number of posts in the thread when hovering over an ID.' ], 'Remove Spoilers': [ false, 'Remove all spoilers in text.' ], 'Reveal Spoilers': [ false, 'Indicate spoilers if Remove Spoilers is enabled, or make the text appear hovered if Remove Spoiler is disabled.' ], 'Normalize URL': [ true, 'Rewrite the URL of the current page, removing slugs and excess slashes, and changing /res/ to /thread/.' ], 'Disable Autoplaying Sounds': [ false, 'Prevent sounds on the page from autoplaying.' ], 'Disable Native Extension': [ true, `${meta.name} is NOT designed to work with the native extension.` ], 'Enable Native Flash Embedding': [ true, 'Activate the native extension\'s Flash embedding if the native extension is disabled.' ], 'Scroll Markers': [ true, 'Mark your posts and replies to them on the scroll bar.' ], }, 'Linkification': { 'Linkify': [ true, 'Convert text into links where applicable.' ], 'Link Title': [ true, 'Replace the link of a supported site with its actual title.', 1 ], 'Link Title in the catalog': [ false, 'Replace the link of a supported site with its actual title in the catalog too. ' + 'Speed up performance for boards that have many embeds (e.g /vt/) if turned off', 2 ], 'Cover Preview': [ true, 'Show preview of supported links on hover.', 1 ], 'Embedding': [ true, 'Embed supported services. Note: Some services don\'t work on HTTPS.', 1 ], 'Auto-embed': [ false, 'Auto-embed Linkify Embeds.', 2 ], 'Floating Embeds': [ false, 'Embed content in a frame that remains in place when the page is scrolled.', 2 ], 'Convert X to xcancel': [ false, 'Rewrite twitter.com / x.com links to xcancel.com so clicks open the privacy front-end instead.', 1 ], 'Convert YouTube to yewtu.be': [ false, 'Rewrite youtube.com / youtu.be links to yewtu.be so clicks open the privacy front-end instead.', 1 ], }, 'Filtering': { 'Anonymize': [ false, 'Make everyone Anonymous.' ], 'Filter': [ true, 'Self-moderation placebo.' ], 'Filtered Backlinks': [ false, 'When enabled, shows backlinks to filtered posts with a line-through decoration. Otherwise, hides the backlinks.', 1 ], 'Filter in Native Catalog': [ true, 'Apply 4chan X filters in native catalog.', 1 ], 'MD5 Quick Filter Notifications': [ true, 'Show notification when quick filtering MD5s using the button or keybind.', 1 ], 'MD5 Quick Filter in the Catalog': [ true, 'Quick filter by MD5 when clicking a thumbnail in the catalog and holding Shift. Disabling falls back on just hiding the thread.', 1 ], 'MD5 Quick Filter in Threads': [ true, 'Quick filter by MD5 when clicking a thumbnail in a thread while holding shift.', 1 ], 'Recursive Hiding': [ true, 'Hide replies of hidden posts, recursively.' ], 'Thread Hiding Buttons': [ true, 'Add buttons to hide entire threads.' ], 'Reply Hiding Buttons': [ true, 'Add buttons to hide single replies.' ], 'Stubs': [ true, 'Show stubs of hidden threads / replies.' ], 'Filter Reason': [ true, 'Show the reason the post was hidden in the stub. If disabled, you can hover over the stub to see the reason.' ], 'Show Threads With Yous': [ false, 'Show hidden threads on the index/catalog when they have unread replies quoting you. They re-hide once the (You) is read.' ], 'Group Hidden Threads By Filter': [ false, 'When showing hidden threads in catalog mode, group them under a header for the filter rule that hid them, with a separate "Manually hidden" section.' ], }, 'Images and Videos': { 'Image Expansion': [ true, 'Expand images / videos.' ], 'Image Hover': [ true, 'Show full image / video on mouseover.' ], 'Image Hover in Catalog': [ true, `Show full image / video on mouseover in ${meta.name} catalog.` ], 'Gallery': [ true, 'Adds a simple and cute image gallery. Has more options in the gallery menu.' ], 'Download All Media': [ true, 'Adds a header shortcut and menu entry to download all images/videos in the current thread/index.' ], 'Persistent Download Media': [ false, 'Keep the "Download all media" dialog open across page loads.', 1 ], 'Fullscreen Gallery': [ false, 'Open gallery in fullscreen mode.', 1 ], 'PDF in Gallery': [ false, 'Show PDF files in gallery.', 1 ], 'Sauce': [ true, 'Add sauce links to images.' ], 'WEBM Metadata': [ true, 'Add link to fetch title metadata from webm videos.' ], 'Video Duration Badge': [ false, 'Show a video length badge on webm/mp4 thumbnails. Reads each video\'s metadata ' + '(lazily, as thumbnails scroll into view), so it uses a little extra bandwidth.' ], 'Reveal Spoiler Thumbnails': [ false, 'Replace spoiler thumbnails with the original image.' ], 'Replace Thumbnails': [ false, 'Replace thumbnails with full-size media inline. Will degrade performance.' ], 'Replace GIF': [ false, 'Replace gif thumbnails with the actual image.', 1 ], 'Replace JPG': [ false, 'Replace jpg thumbnails with the actual image.', 1 ], 'Replace PNG': [ false, 'Replace png thumbnails with the actual image.', 1 ], 'Replace WEBM': [ false, 'Replace webm, mp4, and ogv thumbnails with the actual video. Probably will degrade browser performance ;)', 1 ], 'Image Prefetching': [ true, 'Add a shortcut icon to the header to turn on image preloading.' ], 'Fappe Tyme': [ true, 'Hide posts without images when header menu item is checked. *hint* *hint*' ], 'Werk Tyme': [ true, 'Hide all post images when header menu item is checked.' ], 'Autoplay': [ true, 'Videos begin playing immediately when opened.' ], 'Restart when Opened': [ false, 'Restart GIFs and WebMs when you hover over or expand them.' ], 'Show Controls': [ true, 'Show controls on videos expanded inline.' ], 'Click Passthrough': [ false, 'Clicks on videos trigger your browser\'s default behavior. Videos can be contracted with button / dragging to the left.', 1 ], 'Allow Sound': [ true, 'Open videos with the sound unmuted.' ], 'Mouse Wheel Volume': [ true, 'Adjust volume of videos with the mouse wheel over the thumbnail/filename/gallery.' ], 'Loop in New Tab': [ true, 'Loop videos opened in their own tabs.' ], 'Volume in New Tab': [ true, `Apply ${meta.name} mute and volume settings to videos opened in their own tabs.` ], 'Enable sound posts': [ true, 'Enable loading audio from [sound=] file names. This audio is fetched from third parties.' ], }, 'Menu': { 'Menu': [ true, 'Add a drop-down menu to posts.' ], 'Report Link': [ true, 'Add a report link to the menu.', 1 ], 'Copy Text Link': [ true, 'Add a link to copy the post\'s text.', 1 ], 'Thread Hiding Link': [ true, 'Add a link to hide entire threads.', 1 ], 'Reply Hiding Link': [ true, 'Add a link to hide single replies.', 1 ], 'Delete Link': [ true, 'Add post and image deletion links to the menu.', 1 ], 'Archive Link': [ true, 'Add an archive link to the menu.', 1 ], 'Edit Link': [ true, 'Add a link to edit the image in Tegaki, /i/\'s painting program. Requires Quick Reply.', 1 ], 'Download Link': [ false, 'Add a download with original filename link to the menu.', 1 ] }, 'Monitoring': { 'Thread Updater': [ true, 'Fetch and insert new replies. Quick toggles in the header menu; more options in Threads & Posts → Updater & Cooldown; sounds in Advanced → Thread updater sound.' ], 'Unread Count': [ true, 'Show the unread posts count in the tab title.' ], 'Quoted Title': [ false, 'Change the page title to reflect you\'ve been quoted.', 1 ], 'Hide Unread Count at (0)': [ false, 'Hide the unread posts count in the tab title when it reaches 0.', 1 ], 'Unread Favicon': [ true, 'Show a different favicon when there are unread posts.' ], 'Unread Line': [ true, 'Show a line to distinguish read posts from unread ones.' ], 'Remember Last Read Post': [ true, 'Remember how far you\'ve read after you close the thread.' ], 'Scroll to Last Read Post': [ true, 'Scroll back to the last read post when reopening a thread.', 1 ], 'Unread Line in Index': [ false, 'Show a line between read and unread posts in threads in the index.', 1 ], 'Remove Thread Excerpt': [ false, 'Replace the excerpt of the thread in the tab title with the board title.' ], 'Thread Stats': [ true, 'Display reply and image count.' ], 'IP Count in Stats': [ true, 'Display the unique IP count in the thread stats.', 1 ], 'Page Count in Stats': [ true, 'Display the page count in the thread stats.', 1 ], 'Purge Position': [ false, 'Update stats more often and add purge position when a thread is close to getting purged, for anons who manage general threads.', 2 ], 'Updater and Stats in Header': [ true, 'Places the thread updater and thread stats in the header instead of floating them.' ], 'Thread Watcher': [ true, 'Bookmark threads. Has more options in the thread watcher menu.' ], 'Fixed Thread Watcher': [ true, 'Makes the thread watcher scroll with the page.', 1 ], 'Persistent Thread Watcher': [ false, 'The thread watcher will be visible when the page is loaded.', 1 ], 'Thread Watcher Attach Controls': [ true, 'Show the Thread Watcher titlebar button for attaching/detaching it to Quick Reply. When disabled, the attach button and Attach Location menu are hidden.', 1 ], 'Mark New IPs': [ false, 'Label each post from a new IP with the thread\'s current IP count.' ], 'Reply Pruning': [ true, 'Add option in header menu to hide old replies in long threads. Activated by default in stickies.' ], 'Prune All Threads': [ false, 'Activate Reply Pruning by default in all threads.', 1 ], 'Detailed Thread Stats': [ true, 'Display page / purge position and unique IP count in the thread stats.', 1 ], 'Scrollbar Markers': [ true, 'Show colored markers along the right edge of the page for tracked posts. Uses the highlight colors from the Style settings.' ], 'Scrollbar Mark Own Posts': [ true, 'Mark your own posts in the scrollbar.', 1 ], 'Scrollbar Mark Quotes You': [ true, 'Mark posts that quote you in the scrollbar.', 1 ], 'Scrollbar Mark Ghost Posts': [ true, 'Mark deleted (ghost) posts in the scrollbar.', 1 ], 'Scrollbar Mark Unread Line': [ true, 'Mark the unread line position in the scrollbar.', 1 ], 'Scrollbar Marker Position': [ 'offset', 'Where markers are drawn: beside the scrollbar (single or 3 columns) or over the scrollbar (single or 3 columns). Over modes require overlay (floating) scrollbars.', 1 ], 'Scrollbar Marker Hover Preview': [ true, 'Show a post preview when hovering a scrollbar marker. Turn off if the scrollbar makes markers hard to hover (e.g. the Over modes on Firefox).', 1 ] }, 'Posting and Captchas': { 'Quick Reply': [ true, 'All-in-one form to reply, create threads, automate dumping and more.' ], 'Persistent QR': [ false, 'The Quick reply won\'t disappear after posting.', 1 ], 'Auto Hide QR': [ true, 'Automatically hide the quick reply when posting.', 2 ], 'Open Post in New Tab': [ true, 'Open new threads in a new tab, and open replies in a new tab if you\'re not already in the thread.', 1 ], 'Remember QR Size': [ false, 'Remember the size of the Quick reply.', 1 ], 'Remember Spoiler': [ false, 'Remember the spoiler state, instead of resetting after posting.', 1 ], 'Clear Name and Options After Posting': [ false, 'Clear the name and options (email) fields after posting and don\'t remember them across sessions. Subject and comment always clear regardless. An "always" persona still overrides this.', 1 ], 'Auto-close Tags': [ true, 'Auto-insert matching closing tags in Quick Reply for board-supported tags like [code], [spoiler], [math], and [sjis].', 1 ], 'QR Drafts': [ false, 'Auto-save your Quick Reply per thread, including attachments up to ~100 MB. Dead-thread drafts auto-clear from the discard bin after a day.', 1 ], 'Show QR Drafts Icon': [ true, 'Show the drafts icon in the Quick Reply. Turn off to declutter and use the "Toggle drafts" keybind instead.', 2 ], 'Allow Browser Autofill': [ false, 'Allow browser/password-manager suggestions for Quick Reply text fields.', 1 ], 'Dump List Remove File First': [ true, 'Dump-list Remove clears the file before removing the queued post.', 1 ], 'Randomize Filename': [ false, 'Set the filename to a random timestamp within the past year. Disabled on /f/.', 1 ], 'Show New Thread Option in Threads': [ true, 'Show the option to post a new / different thread from inside a thread.', 1 ], 'Hide Original Post Form': [ true, 'Hide the native board-index post form until you open Original Form or Start a Thread.', 1 ], 'Show Upload Progress': [ true, 'Track progress of file uploads as percentage in submit button.', 1 ], 'Cooldown': [ true, 'Indicate the remaining time before posting again.', 1 ], 'Posting Success Notifications': [ true, 'Show notifications on successful post creation or file uploading.', 1 ], 'Auto-load captcha': [ false, 'Automatically load the captcha in the QR even if your post is empty.', 1 ], 'Post on Captcha Completion': [ false, 'Submit the post immediately when the captcha is completed.', 1 ], 'Avoid OffscreenCanvas': [ false, 'Do not use OffscreenCanvas when converting images, workaround for ' + 'this LibreWolf bug', 1 ], 'Auto-process Images': [ true, 'Automatically convert unsupported image formats and resize oversized image uploads in Quick Reply.', 1 ], 'Strip Video Audio': [ true, 'Remove audio from MP4 and WebM uploads in Quick Reply on boards that do not allow audio.', 1 ], 'Dogiri Editor': [ true, 'Add a button to edit attached videos in Dōgiri, the video editor. Requires Quick Reply.', 1 ], 'Dogiri Oversize Render Warning': [ true, 'Ask for confirmation before rendering in Dōgiri when the estimated file size is over the board limit.', 2 ], 'Dogiri Filmstrip': [ true, 'Show frames from the video behind the Dōgiri timeline.', 2 ], 'Dogiri Timeline Size': [ 'small', 'Size of the Dōgiri timeline and its frame thumbnails.', 2 ], 'Dogiri Autosave': [ true, 'Keep an automatic backup of unsaved Dōgiri edits and offer to restore it when the same video is opened again.', 2 ], 'Comment Preview': [ false, 'Live preview of how your comment will render. Shows as a draggable floating window; the arrow in its header docks it inline as a literal post in the thread (and back).', 1 ], 'Comment Preview Default Mode': [ 'attached', 'Choose where the comment preview starts when Quick Reply opens.', 2 ], 'Comment Preview Attach Location': [ 'auto', 'Where the attached floating preview docks to Quick Reply. Auto prefers bottom, but avoids the Thread Watcher when it is already attached there.', 2 ], 'Comment Preview Remember Float Position': [ false, 'Remember a manually dragged floating preview position when Quick Reply is closed and reopened.', 2 ], 'Comment Preview Thread Behavior': [ 'normal', 'Preview behavior in threads.', 2 ], 'Comment Preview Catalog Behavior': [ 'normal', 'Preview behavior on catalog/index/archive.', 2 ], 'Show Comment Preview Header Icon': [ true, 'Show the comment preview toggle icon in the Quick Reply titlebar.', 2 ], 'Force Noscript Captcha': [ false, 'Use the non-Javascript fallback captcha even if Javascript is enabled.' ], 'Stacked TCaptcha': [ false, 'Show 4chan\'s TCaptcha as a stacked image grid in Quick Reply instead of the default slider/next UI. Supports keyboard controls: arrows/1-9/Enter pick tiles, Backspace revisits answers.' ], 'Stacked TCaptcha Style': [ 'inline', 'Layout for the stacked captcha\'s progress chips and review screen. Classic row: large chips on their own row with status text. Inline chips: compact chips beside the Get Captcha button. Stepper dots: minimal dots beside the counter; hover to peek, click to edit.', 2 ], 'Pass Link': [ false, 'Add a 4chan Pass login link to the bottom of the page.' ] }, 'Quote Links': { 'Quote Backlinks': [ true, 'Add quote backlinks.' ], 'OP Backlinks': [ true, 'Add backlinks to the OP.', 1 ], 'Bottom Backlinks': [ false, 'Place backlinks at the bottom of posts.', 1 ], 'Quote Inlining': [ true, 'Inline quoted post on click.' ], 'Inline Cross-thread Quotes Only': [ false, 'Don\'t inline quote links when the posts are visible in the thread.', 1 ], 'Quote Hash Navigation': [ false, 'Include an extra link after quotes for autoscrolling to quoted posts.', 1 ], 'Forward Hiding': [ true, 'Hide original posts of inlined backlinks.', 1 ], 'Quote Previewing': [ true, 'Show quoted post on hover.' ], 'Quote Highlighting': [ true, 'Highlight the previewed post.', 1 ], 'Resurrect Quotes': [ true, 'Link dead quotes to the archives, and support inlining/previewing of archive links like quote links.' ], 'Fetch Ghost Posts': [ false, 'Fetch deleted posts from the configured archive and insert them inline.', 1 ], 'Remember Your Posts': [ true, 'Remember your posting history.' ], 'Mark Quotes of You': [ true, 'Add \'(You)\' to quotes linking to your posts.', 1 ], 'Highlight Posts Quoting You': [ true, 'Highlights any posts that contain a quote to your post.', 1 ], 'Highlight Own Posts': [ true, 'Highlights own posts.', 1 ], 'Highlight Ghost Posts': [ true, 'Highlights deleted posts that have been restored from an archive.', 1 ], 'Mark OP Quotes': [ true, 'Add \'(OP)\' to OP quotes.' ], 'Mark Cross-thread Quotes': [ true, 'Add \'(Cross-thread)\' to cross-threads quotes.' ], 'Quote Threading': [ true, 'Add option in header menu to thread conversations.' ] } }, imageExpansion: { 'Fit width': [ true, '' ], 'Fit height': [ false, '' ], 'Scroll into view': [ true, 'Scroll down when expanding images to bring the full image into view.' ], 'Expand spoilers': [ true, 'Expand all images along with spoilers.' ], 'Expand videos': [ true, 'Expand all images also expands videos.' ], 'Expand from here': [ false, 'Expand all images only from current position to thread end.' ], 'Expand thread only': [ false, 'In index, expand all images only within the current thread.' ], 'Advance on contract': [ false, 'Advance to next post when contracting an expanded image.' ] }, gallery: { 'Hide Thumbnails': [ false ], 'Fit Width': [ true ], 'Fit Height': [ true ], 'Stretch to Fit': [ false ], 'Scroll to Post': [ true ], 'Grid Thumbnails': [ false, 'Lay gallery thumbnails out in a grid instead of a single column.' ], 'Gallery Columns': [ 3, 'Number of thumbnail columns to show in grid mode. Columns stop being added once the strip would exceed 75% of the screen width. 0 disables the image preview and shows fullscreen thumbnails (click a thumbnail to open it in a lightbox).' ], 'Gallery Thumbnails Position': [ 'right', 'Which edge of the gallery the thumbnail strip docks to: top, bottom, left or right.' ], 'Slide Delay': [ 6.0 ] }, 'Default Volume': 1.0, 'Thread Watcher Thumbnail Size': 40, 'Thread Watcher Thumbnail Preview Size': 20, 'Thread Watcher Max Height': 210, 'Thread Watcher Max Width': 250, 'Thread Watcher Sort': 'manual', 'Thread Watcher Sort 2': '', 'Thread Watcher Attach Controls': true, 'Thread Watcher Attached': false, 'Thread Watcher Attach Location': 'bottom', 'Thread Title': 'excerpt', 'Unread Title Count': 'always', 'Icon Set': 'fontAwesome', 'Comment Preview Position': 'thread', 'Comment Preview Default Mode': 'attached', 'Comment Preview Last Mode': 'attached', 'Comment Preview Attach Location': 'auto', 'Comment Preview Inline Behavior': 'scroll', 'Comment Preview Remember Float Position': false, 'Comment Preview Float Position': {}, 'Comment Preview Thread Behavior': 'normal', 'Comment Preview Catalog Behavior': 'normal', 'Show Comment Preview Header Icon': true, 'Spoiler Mode': 'default', 'Settings Menu Layout': 'vertical', 'Mobile Layout': 'auto', 'Mobile Bar Layout': 'icons-bottom', 'Mobile Replies Popup': true, 'Mobile Watcher View': 'grid', threadWatcher: { 'Current Board': [ false, 'Only show watched threads from the current board.' ], 'Auto Update Thread Watcher': [ true, 'Periodically check status of watched threads.' ], 'Auto Watch': [ true, 'Automatically watch threads you start.' ], 'Auto Watch Reply': [ true, 'Automatically watch threads you reply to.' ], 'Auto Prune': [ false, 'Automatically remove all dead threads.' ], 'Auto Prune Read Only': [ false, 'Automatically remove dead threads you have already read.' ], 'Show Page': [ true, 'Show what page watched threads are on.' ], 'Show Unread Count': [ true, 'Show number of unread posts in watched threads.' ], 'Show Mark All Read Icon': [ true, 'Show the mark-all-read icon in the thread watcher header.' ], 'Show Mark Thread Read Icons': [ false, 'Show a per-thread mark-as-read icon in each watched thread entry.' ], 'Show Undo Button': [ false, 'After "Mark all read", the watcher icon briefly becomes an Undo button with a countdown ring. Click it within a few seconds to restore.' ], 'Show Site Prefix': [ true, 'When multiple sites are shown in the thread watcher, add a prefix to board names to distinguish them.' ], 'Show OP Thumbnails': [ false, 'Show OP thumbnails in watched thread entries.' ], 'Thread Watcher Thumbnail Hover': [ false, 'Show a larger OP thumbnail preview when hovering watched-thread thumbnails.' ], 'Require OP Quote Link': [ false, 'For purposes of thread watcher highlighting, only consider posts with a quote link to the OP as replies to the OP.' ] }, filter: { general: '', postID: `\ # Highlight dubs on [s4s]: #/(\\d)\\1$/;highlight;top:no;boards:s4s\ `, name: `\ # Filter any namefags: #/^(?!Anonymous$)/\ `, uniqueID: `\ # Filter a specific ID: #/Txhvk1Tl/\ `, tripcode: `\ # Filter any tripfag #/^!/\ `, capcode: `\ # Set a custom class for mods: #/Mod$/;highlight:mod;op:yes # Set a custom class for admins: #/Admin$/;highlight:admin;op:yes\ `, pass: `\ # Filter anyone using since4pass: #/./\ `, email: '', subject: `\ # Filter Generals on /v/: #/general/i;boards:v;op:only\ `, comment: `\ # Filter Stallman copypasta on /g/: #/what you\'re refer+ing to as linux/i;boards:g # Filter posts with 20 or more quote links: #/(?:>>\\d(?:(?!>>\\d)[^])*){20}/ # Filter posts like T H I S / H / I / S: #/^>?\\s?\\w\\s?(\\w)\\s?(\\w)\\s?(\\w).*$[\\s>]+\\1[\\s>]+\\2[\\s>]+\\3/im\ `, flag: '', filename: '', dimensions: `\ # Highlight potential wallpapers: #/1920x1080/;op:yes;highlight;top:no;boards:w,wg\ `, filesize: '', MD5: '' }, easyFilters: '', sauces: `\ # Known filename formats: https://www.pixiv.net/member_illust.php?mode=medium&illust_id=%$1;regexp:/^(\\d+)_p\\d+/ javascript:void(open("https://www.deviantart.com/"+%$1.replace(/_/g,"-")+"/art/"+parseInt(%$2,36)));regexp:/^\\w+_by_(\\w+)[_-]d([\\da-z]{6})\\b/ https://imgur.com/%$1;regexp:/^(?![a-zA-Z][a-z]{6})(?![A-Z]{7})(?!\\d{7})([\\da-zA-Z]{7})(?: \\(\\d+\\))?\\.\\w+$/ https://flickr.com/photo.gne?id=%$1;regexp:/^(\\d+)_[\\da-f]{10}(?:_\\w)*\\b/ https://www.facebook.com/photo.php?fbid=%$1;regexp:/^\\d+_(\\d+)_\\d+_[no]\\b/ # Reverse image search: https://www.google.com/searchbyimage?sbisrc=4chanx&image_url=%IMG&safe=off https://yandex.com/images/search?rpt=imageview&url=%IMG #//tineye.com/search?url=%IMG #//www.bing.com/images/search?q=imgurl:%IMG&view=detailv2&iss=sbi#enterInsights #https://lens.google.com/uploadbyurl?url=%IMG;text:lens # Specialized reverse image search: //iqdb.org/?url=%IMG https://trace.moe/?auto&url=%IMG;text:trace #//3d.iqdb.org/?url=%IMG #//saucenao.com/search.php?url=%IMG # "View Same" in archives: http://eye.swfchan.com/search/?q=%name;types:swf #https://desuarchive.org/_/search/image/%sMD5/ #https://archive.4plebs.org/_/search/image/%sMD5/ #https://boards.fireden.net/_/search/image/%sMD5/ #https://foolz.fireden.net/_/search/image/%sMD5/ # Other tools: #http://exif.regex.info/exif.cgi?imgurl=%URL #//imgops.com/start?url=%URL;types:gif,jpg,png #//www.gif-explode.com/%URL;types:gif\ `, FappeT: { werk: false }, 'Custom CSS': true, customCSSHome: false, siteStyle: '', siteStyleHome: false, stylingSectionSiteStyle: true, stylingSectionHighlights: true, stylingSectionScrollbarMarkers: true, stylingSectionTextColors: true, stylingSectionCustomCSS: true, stylingSectionsInitialized: false, customSiteThemes: [], savedHighlightPalettes: [], sfwNsfwMode: 'auto', textColorMode: 'default', 'Text Color': '', 'Link Text Color': '', 'Quote Text Color': '', 'Dead Link Text Color': '', 'Scroll Marker Match Highlights': true, 'Scroll Marker Own Match Highlight': true, 'Scroll Marker You Match Highlight': true, 'Scroll Marker Ghost Match Highlight': true, 'Highlight Own Color': '', 'Highlight You Color': '', 'Highlight Ghost Color': '', 'Highlight Own Text Auto': true, 'Highlight You Text Auto': true, 'Highlight Ghost Text Auto': true, 'Highlight Own Text Mode': 'default', 'Highlight You Text Mode': 'default', 'Highlight Ghost Text Mode': 'default', 'Highlight Own Text Color': '', 'Highlight Own Link Color': '', 'Highlight Own Quote Color': '', 'Highlight Own Dead Link Color': '', 'Highlight You Text Color': '', 'Highlight You Link Color': '', 'Highlight You Quote Color': '', 'Highlight You Dead Link Color': '', 'Highlight Ghost Text Color': '', 'Highlight Ghost Link Color': '', 'Highlight Ghost Quote Color': '', 'Highlight Ghost Dead Link Color': '', 'Highlight Own Opacity': '', 'Highlight You Opacity': '', 'Highlight Ghost Opacity': '', 'Thread Highlight Edge Width': 3, 'Highlight Own Edge Width': 3, 'Highlight You Edge Width': 3, 'Highlight Ghost Edge Width': 3, 'Highlight Own Border Style': 'dashed', 'Highlight You Border Style': 'dashed', 'Highlight Ghost Border Style': 'dotted', 'Highlight Own Background': false, 'Highlight You Background': false, 'Highlight Ghost Background': false, 'Enable Thread Highlights': true, 'Enable Catalog Highlights': false, 'Catalog Highlight Own Posts': true, 'Catalog Highlight Watched Threads': true, 'Catalog Highlight Own Color': '', 'Catalog Highlight Own Opacity': '', 'Catalog Highlight Own Background': false, 'Catalog Highlight Watched Color': '', 'Catalog Highlight Watched Opacity': '', 'Catalog Highlight Watched Background': false, 'Catalog Highlight Border Width': 2, 'Catalog Highlight Own Border Width': 2, 'Catalog Highlight Watched Border Width': 2, 'Catalog Highlight Own Border Style': 'solid', 'Catalog Highlight Watched Border Style': 'solid', 'Catalog Highlight Own Location': 'tile', 'Catalog Highlight Watched Location': 'tile', 'Catalog Highlight Own Glow': false, 'Catalog Highlight Own Glow Intensity': 0.5, 'Catalog Highlight Watched Glow': false, 'Catalog Highlight Watched Glow Intensity': 0.5, 'Catalog Highlight Filter Posts': true, 'Catalog Highlight Filter Color': '', 'Catalog Highlight Filter Opacity': '', 'Catalog Highlight Filter Background': false, 'Catalog Highlight Filter Location': 'image', 'Catalog Highlight Filter Border Width': 2, 'Catalog Highlight Filter Border Style': 'solid', 'Catalog Highlight Filter Glow': true, 'Catalog Highlight Filter Glow Intensity': 0.5, 'Catalog Highlight Filter Text Mode': 'default', 'Catalog Highlight Filter Text Color': '', 'Catalog Highlight Filter Subject Color': '', 'Catalog Highlight Filter Link Color': '', 'Catalog Highlight Filter Quote Color': '', 'Catalog Highlight Filter Dead Link Color': '', 'Catalog Highlight Own Text Mode': 'default', 'Catalog Highlight Own Text Color': '', 'Catalog Highlight Own Subject Color': '', 'Catalog Highlight Own Link Color': '', 'Catalog Highlight Own Quote Color': '', 'Catalog Highlight Own Dead Link Color': '', 'Catalog Highlight Watched Text Mode': 'default', 'Catalog Highlight Watched Text Color': '', 'Catalog Highlight Watched Subject Color': '', 'Catalog Highlight Watched Link Color': '', 'Catalog Highlight Watched Quote Color': '', 'Catalog Highlight Watched Dead Link Color': '', 'Scroll Marker Own Color': '', 'Scroll Marker You Color': '', 'Scroll Marker Ghost Color': '', 'Scroll Marker Unread Color': '', 'Scroll Marker Own Opacity': '', 'Scroll Marker You Opacity': '', 'Scroll Marker Ghost Opacity': '', 'Scroll Marker Unread Opacity': '', 'Scrollbar Marker Position': 'offset', Index: { 'Index Mode': 'paged', 'Previous Index Mode': 'paged', 'Index Size': 'small', 'Catalog Columns': 'auto', 'Show Replies': [true, 'Show replies in the index, and also in the catalog if "Catalog hover expand" is checked.'], 'Catalog Hover Expand': [false, 'Expand the comment and show more details when you hover over a thread in the catalog.'], 'Catalog Hover Toggle': [true, 'Turn "Catalog hover expand" on and off by clicking in the catalog.'], 'Pin Watched Threads': [false, 'Move watched threads to the start of the index.'], 'Anchor Hidden Threads': [true, 'Move hidden threads to the end of the index.'], 'Refreshed Navigation': [false, 'Refresh index when navigating through pages.'] }, Header: { 'Fixed Header': true, 'Header auto-hide': false, 'Header auto-hide on scroll': false, 'Bottom Header': false, 'Centered links': false, 'Header catalog links': false, 'Bottom Board List': true, 'Hide Board Banner': false, 'Shortcut Icons': true, 'Custom Board Navigation': true }, archives: { archiveLists: 'https://4chenz.github.io/archives.json/archives.json', lastarchivecheck: 0, archiveAutoUpdate: true }, externalCatalogURLs: `\ //catalog.neet.tv/%board/;boards:4chan.org:3,a,adv,an,asp,biz,c,cgl,ck,cm,co,diy,f,fa,fit,g,gd,his,i,int,jp,k,lgbt,lit,m,mlp,mu,n,news,o,out,p,po,pol,s4s,sci,sp,tg,toy,trv,tv,v,vg,vip,vp,vr,w,wg,wsg,wsr,x\ `, boardnav: `\ [ toggle-all ] [current-index-text:"Index" current-catalog-text:"Catalog" current-expired-text:"Expired" current-archive-text:"Archive"] [external-text:"FAQ","${meta.faq}"]\ `, favoriteBoards: '', favoriteBoardsSeeded: false, QR: { 'QR.personas': `\ # These are examples - remove the leading # on any line to try that option out. # --- Builds a name dropdown on /g/ with 3 options. #name:"Anonymous";boards:g #name:"Based Department";boards:g #name:"Chad";boards:g # --- All 3 show in the subject dropdown; "Programming General" is pre-filled. #subject:"Linux";boards:g #subject:"Sticky Suggestions";boards:g #subject:"Programming General";boards:g;always # --- Dropdown shows both; "fortune" pre-fills because it's lower in the list. #options:"sage";boards:g;always #options:"fortune";boards:g;always # --- Board-scoped: only appears on the listed boards --- #subject:"/v/ permavirgin general";boards:v,vg # --- Global: no boards key means every board --- #name:"Anonymous" # --- Tripcode: goes in the name field as name#secret (not password) --- #name:"Code Monkey#s3cret";boards:g # --- Password: post deletion password, no dropdown, global only --- #password:"hunter2"\ `, sjisPreview: false }, jsWhitelist: '', captchaLanguage: '', time: '%m/%d/%y(%a)%H:%M:%S', timeLocale: '', RelativeTime: 'Hover', backlink: '>>%id', pastedname: 'file', fileInfo: '%l %d (%p%s, %r%g)', favicon: 'ferongr', usercss: userCss, hotkeys: { 'Toggle board list': [ 'Ctrl+b', 'Toggle the full board list.' ], 'Toggle header': [ 'Shift+h', 'Toggle the auto-hide option of the header.' ], 'Open empty QR': [ 'q', 'Open QR without post number inserted.' ], 'Open QR': [ 'Shift+q', 'Open QR with post number inserted.' ], 'Open settings': [ 'Alt+o', 'Open Settings.' ], 'Close': [ 'Esc', 'Close dialogs or notifications.' ], 'Spoiler tags': [ 'Ctrl+s', 'Insert spoiler tags.' ], 'Code tags': [ 'Alt+c', 'Insert code tags.' ], 'Eqn tags': [ 'Alt+e', 'Insert eqn tags.' ], 'Math tags': [ 'Alt+m', 'Insert math tags.' ], 'SJIS tags': [ 'Alt+a', 'Insert SJIS tags.' ], 'Toggle sage': [ 'Alt+s', 'Toggle sage in options field.' ], 'Toggle comment preview': [ 'Alt+v', 'Toggle the QR comment preview.' ], 'Toggle drafts': [ 'Alt+d', 'Toggle the QR drafts panel.' ], 'Toggle Cooldown': [ 'Alt+Comma', 'Toggle custom cooldown timer.' ], 'Post from URL': [ 'Alt+l', 'Post from URL.' ], 'Add new post': [ 'Alt+n', 'Add new post to the QR dump list.' ], 'Submit QR': [ 'Ctrl+Enter', 'Submit post.' ], 'Watch': [ 'w', 'Watch thread.' ], 'Watch (catalog click)': [ 'Ctrl+Shift', 'Catalog-click modifiers for watching threads. Leave empty to disable.' ], 'Hide thread (catalog click)': [ 'Shift', 'Hold these modifier(s) and click a catalog thread to hide it. Leave empty to disable.' ], 'Undo last hide/filter': [ 'Ctrl+z', 'Undo your most recent thread hide or MD5 quick-filter. Repeat to undo earlier ones (up to 10). Ignored while typing in a text field.' ], 'Update': [ 'r', 'Update the thread / refresh the index.' ], 'Update thread watcher': [ 'Shift+r', 'Manually refresh thread watcher.' ], 'Toggle thread watcher': [ 't', 'Toggle visibility of thread watcher.' ], 'Toggle threading': [ 'Shift+t', 'Toggle threading.' ], 'Mark thread read': [ 'Ctrl+0', 'Mark thread read from index (requires "Unread Line in Index").' ], 'Expand image': [ 'Shift+e', 'Expand selected image.' ], 'Expand images': [ 'e', 'Expand all images.' ], 'Open Gallery': [ 'g', 'Opens the gallery.' ], 'Next Gallery Image': [ 'Right', 'Go to the next image in gallery mode.' ], 'Previous Gallery Image': [ 'Left', 'Go to the previous image in gallery mode.' ], 'Advance Gallery': [ 'Enter', 'Go to next image or, if Autoplay is off, play video.' ], 'Pause': [ 'p', 'Pause/play videos in the gallery.' ], 'Slideshow': [ 'Ctrl+Right', 'Toggle the gallery slideshow mode.' ], 'Rotate image clockwise': [ 'Shift+Right', 'Rotate image clockwise in gallery.' ], 'Rotate image anticlockwise': [ 'Shift+Left', 'Rotate image anticlockwise in gallery.' ], 'Download Gallery Image': [ 'Shift+j', 'Download current image in gallery.' ], 'Download all media': [ 'Shift+d', 'Download all media in the current thread/index.' ], 'fappeTyme': [ 'f', 'Toggle Fappe Tyme.' ], 'werkTyme': [ 'Shift+w', 'Toggle Werk Tyme.' ], 'Front page': [ '1', 'Jump to front page.' ], 'Open front page': [ 'Shift+1', 'Open front page in a new tab.' ], 'Next page': [ 'Ctrl+Right', 'Jump to the next page.' ], 'Previous page': [ 'Ctrl+Left', 'Jump to the previous page.' ], 'Paged mode': [ 'Alt+1', 'Open the index in paged mode.' ], 'Infinite scrolling mode': [ 'Alt+2', 'Open the index in infinite scrolling mode.' ], 'All pages mode': [ 'Alt+3', 'Open the index in all threads mode.' ], 'Open catalog': [ 'Shift+c', 'Open the catalog of the current board.' ], 'Search form': [ 'Ctrl+Alt+s', 'Focus the search field on the board index.' ], 'Cycle sort type': [ 'Alt+x', 'Cycle through index sort types.' ], 'Next thread': [ 'Ctrl+Down', 'See next thread.' ], 'Previous thread': [ 'Ctrl+Up', 'See previous thread.' ], 'Expand thread': [ 'Ctrl+e', 'Expand thread.' ], 'Open thread': [ 'o', 'Open thread in current tab.' ], 'Open thread tab': [ 'Shift+o', 'Open thread in new tab.' ], 'Next reply': [ 'j', 'Select next reply.' ], 'Previous reply': [ 'k', 'Select previous reply.' ], 'Deselect reply': [ 'Shift+d', 'Deselect reply.' ], 'Hide': [ 'x', 'Hide thread.' ], 'Quick Filter MD5': [ '5', 'Add the MD5 of the selected image to the filter list.' ], 'Previous Post Quoting You': [ 'Alt+Up', 'Scroll to the previous post that quotes you.' ], 'Next Post Quoting You': [ 'Alt+Down', 'Scroll to the next post that quotes you.' ] }, updater: { checkbox: { 'Beep': [ false, 'Beep on new post to completely read thread.' ], 'Beep Quoting You': [ false, 'Beep on new post quoting you.' ], 'Auto Scroll': [ false, 'Scroll updated posts into view. Only enabled at bottom of page.' ], 'Bottom Scroll': [ false, 'Always scroll to the bottom, not the first new post. Useful for event threads.' ], 'Scroll BG': [ false, 'Auto-scroll background tabs.' ], 'Auto Update': [ true, 'Automatically fetch new posts.' ], 'Optional Increase': [ false, 'Increase the intervals between updates on threads without new posts.' ] }, 'Interval': 5 }, customCooldown: 0, customCooldownEnabled: true, 'Thread Quotes': false, 'Max Replies': 1000, 'Autohiding Scrollbar': false, position: { 'embedding.position': 'top: 50px; right: 0px;', 'thread-stats.position': 'bottom: 0px; right: 0px;', 'updater.position': 'bottom: 0px; left: 0px;', 'thread-watcher.position': 'top: 50px; left: 0px;', 'qr.position': 'top: 50px; right: 0px;', 'download-all-picker.position': 'top: 100px; right: 60px;' }, fourchanImageHost: 'i.4cdn.org', hiddenPSAList: [{}], knownBanners: banners.join(','), passMessageClosed: false, 'PSAseen': [[]], XEmbedder: 'fxt', fxtLang: '', fxtUrl: 'https://api.fxtwitter.com', fxtMaxReplies: 5, tfUrl: 'https://tf.rita.moe', beepSource: '', beepVolume: 1, soundLibrary: [[]], boardSounds: [{}], defaultSoundId: '', }; const styleVariantKeys = [ 'siteStyle', 'usercss', 'textColorMode', 'Text Color', 'Link Text Color', 'Quote Text Color', 'Dead Link Text Color', 'Highlight Own Color', 'Highlight You Color', 'Highlight Ghost Color', 'Highlight Own Opacity', 'Highlight You Opacity', 'Highlight Ghost Opacity', 'Thread Highlight Edge Width', 'Highlight Own Edge Width', 'Highlight You Edge Width', 'Highlight Ghost Edge Width', 'Highlight Own Border Style', 'Highlight You Border Style', 'Highlight Ghost Border Style', 'Highlight Own Text Mode', 'Highlight You Text Mode', 'Highlight Ghost Text Mode', 'Highlight Own Text Color', 'Highlight Own Link Color', 'Highlight Own Quote Color', 'Highlight Own Dead Link Color', 'Highlight You Text Color', 'Highlight You Link Color', 'Highlight You Quote Color', 'Highlight You Dead Link Color', 'Highlight Ghost Text Color', 'Highlight Ghost Link Color', 'Highlight Ghost Quote Color', 'Highlight Ghost Dead Link Color', 'Catalog Highlight Own Color', 'Catalog Highlight Own Opacity', 'Catalog Highlight Own Background', 'Catalog Highlight Watched Color', 'Catalog Highlight Watched Opacity', 'Catalog Highlight Watched Background', 'Catalog Highlight Border Width', 'Catalog Highlight Own Border Width', 'Catalog Highlight Watched Border Width', 'Catalog Highlight Own Border Style', 'Catalog Highlight Watched Border Style', 'Catalog Highlight Own Location', 'Catalog Highlight Watched Location', 'Catalog Highlight Own Glow', 'Catalog Highlight Own Glow Intensity', 'Catalog Highlight Watched Glow', 'Catalog Highlight Watched Glow Intensity', 'Catalog Highlight Filter Posts', 'Catalog Highlight Filter Color', 'Catalog Highlight Filter Opacity', 'Catalog Highlight Filter Background', 'Catalog Highlight Filter Location', 'Catalog Highlight Filter Border Width', 'Catalog Highlight Filter Border Style', 'Catalog Highlight Filter Glow', 'Catalog Highlight Filter Glow Intensity', 'Catalog Highlight Filter Text Mode', 'Catalog Highlight Filter Text Color', 'Catalog Highlight Filter Subject Color', 'Catalog Highlight Filter Link Color', 'Catalog Highlight Filter Quote Color', 'Catalog Highlight Filter Dead Link Color', 'Catalog Highlight Own Text Mode', 'Catalog Highlight Own Text Color', 'Catalog Highlight Own Subject Color', 'Catalog Highlight Own Link Color', 'Catalog Highlight Own Quote Color', 'Catalog Highlight Own Dead Link Color', 'Catalog Highlight Watched Text Mode', 'Catalog Highlight Watched Text Color', 'Catalog Highlight Watched Subject Color', 'Catalog Highlight Watched Link Color', 'Catalog Highlight Watched Quote Color', 'Catalog Highlight Watched Dead Link Color', 'Scroll Marker Own Color', 'Scroll Marker You Color', 'Scroll Marker Ghost Color', 'Scroll Marker Unread Color', 'Scroll Marker Own Opacity', 'Scroll Marker You Opacity', 'Scroll Marker Ghost Opacity', 'Scroll Marker Unread Opacity', 'Scroll Marker Own Match Highlight', 'Scroll Marker You Match Highlight', 'Scroll Marker Ghost Match Highlight', ]; for (const k of styleVariantKeys) { const cfg = Config; cfg[`${k} SFW`] = cfg[k]; cfg[`${k} NSFW`] = cfg[k]; } const debounce = (wait, fn, leading = true) => { let lastCall = 0; let timeout = null; let that = null; let args = null; const exec = function () { lastCall = Date.now(); return fn.apply(that, args); }; return function () { args = arguments; that = this; if (leading && lastCall < (Date.now() - wait)) { exec(); return; } if (timeout !== null) clearTimeout(timeout); timeout = setTimeout(exec, wait); }; }; const dict = () => Object.create(null); dict.clone = function (obj) { if ((typeof obj !== 'object') || (obj === null)) { return obj; } else if (obj instanceof Array) { const arr = []; for (let i = 0, end = obj.length; i < end; i++) { arr.push(dict.clone(obj[i])); } return arr; } else { const map = Object.create(null); for (var key in obj) { var val = obj[key]; map[key] = dict.clone(val); } return map; } }; dict.json = (str) => dict.clone(JSON.parse(str)); const SECOND = 1000; const MINUTE = SECOND * 60; const HOUR = MINUTE * 60; const DAY = HOUR * 24; const platform = typeof window.GM_xmlhttpRequest !== 'undefined' ? 'userscript' : 'crx'; const PageContextFunctions = { stubCloneTopNav: () => { window.cloneTopNav = function () { }; }, disableNativeExtension: () => { try { const settings = JSON.parse(localStorage.getItem('4chan-settings') || '{}') || {}; if (settings.disableAll) return; settings.disableAll = true; localStorage.setItem('4chan-settings', JSON.stringify(settings)); } catch (error) { Object.defineProperty(window, 'Config', { value: { disableAll: true } }); } }, disableNativeExtensionNoStorage: () => { Object.defineProperty(window, 'Config', { value: { disableAll: true } }); }, prettyPrint: ({ id }) => { window.prettyPrint?.((function () { }), document.getElementById(id).parentNode); }, exposeVersion: ({ buildDate, version }) => { const date = +buildDate; Object.defineProperty(window, 'fourchanXT', { value: Object.freeze({ version, get buildDate() { return new Date(date); }, }), writable: false, }); }, initMain: () => { document.documentElement.classList.add('js-enabled'); window.FCX = {}; }, initFlash: () => { if (JSON.parse(localStorage['4chan-settings'] || '{}').disableAll) window.SWFEmbed.init(); }, initFlashNoStorage: () => { window.SWFEmbed.init(); }, setThreadId: () => { window.Main.tid = location.pathname.split(/\/+/)[3]; }, fourChanPrettyPrintListener: () => { window.addEventListener('prettyprint', (e) => window.dispatchEvent(new CustomEvent('prettyprint:cb', { detail: { ID: e.detail.ID, i: e.detail.i, html: window.prettyPrintOne(e.detail.html) } })), false); }, fourChanMathjaxListener: () => { window.addEventListener('mathjax', function (e) { const target = e.target; const scriptURL = 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.9/MathJax.js?config=TeX-AMS_HTML-full'; const scriptSelector = 'script[src^="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.9/MathJax.js"]'; const queueTypeset = () => { if (!window.MathJax?.Hub) return false; window.MathJax.Hub.Queue(['Typeset', window.MathJax.Hub, target]); return true; }; if (queueTypeset()) return; if (!document.querySelector(scriptSelector)) { if (!document.getElementById('fourchanx-mathjax-config')) { const config = document.createElement('script'); config.id = 'fourchanx-mathjax-config'; config.type = 'text/x-mathjax-config'; config.text = "MathJax.Hub.Config({extensions:['Safe.js'],tex2jax:{processRefs:false,processEnvironments:false,preview:'none',inlineMath:[['[math]','[/math]']],displayMath:[['[eqn]','[/eqn]']]},Safe:{allow:{URLs:'none',classes:'none',cssIDs:'none',styles:'none',fontsize:'none',require:'none'}},displayAlign:'left',messageStyle:'none'});"; document.head.appendChild(config); } const script = document.createElement('script'); script.src = scriptURL; document.head.appendChild(script); } const script = document.querySelector(scriptSelector); if (script) { script.addEventListener('load', () => { queueTypeset(); }, false); } let tries = 0; const timer = window.setInterval(() => { if (queueTypeset() || ++tries > 200) { window.clearInterval(timer); } }, 50); }, false); }, typesetMathjax: ({ id }) => { const target = document.getElementById(id); if (!target) return; const scriptURL = 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.9/MathJax.js?config=TeX-AMS_HTML-full'; const scriptSelector = 'script[src^="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.9/MathJax.js"]'; const queueTypeset = () => { if (!window.MathJax?.Hub) return false; window.MathJax.Hub.Queue(['Typeset', window.MathJax.Hub, target], () => { if (!target.querySelector('.MathJax, .MathJax_Display')) return; for (const fallback of target.querySelectorAll('.qr-math-fallback')) { fallback.remove(); } }); return true; }; if (queueTypeset()) return; if (!document.querySelector(scriptSelector)) { if (!document.getElementById('fourchanx-mathjax-config')) { const config = document.createElement('script'); config.id = 'fourchanx-mathjax-config'; config.type = 'text/x-mathjax-config'; config.text = "MathJax.Hub.Config({extensions:['Safe.js'],tex2jax:{processRefs:false,processEnvironments:false,preview:'none',inlineMath:[['[math]','[/math]']],displayMath:[['[eqn]','[/eqn]']]},Safe:{allow:{URLs:'none',classes:'none',cssIDs:'none',styles:'none',fontsize:'none',require:'none'}},displayAlign:'left',messageStyle:'none'});"; document.head.appendChild(config); } const script = document.createElement('script'); script.src = scriptURL; document.head.appendChild(script); } const script = document.querySelector(scriptSelector); if (script) { script.addEventListener('load', () => { queueTypeset(); }, false); } let tries = 0; const timer = window.setInterval(() => { if (queueTypeset() || ++tries > 200) { window.clearInterval(timer); } }, 50); }, disable4chanIdHl: () => { window.clickable_ids = false; for (var node of document.querySelectorAll('.posteruid, .capcode')) { node.removeEventListener('click', window.idClick, false); } }, initTinyBoard: ({ boardID, threadID }) => { threadID = +threadID; const form = document.querySelector('form[name="post"]'); if (!form) return; window.$(document).ajaxComplete(function (event, request, settings) { let postID; if (settings.url !== form.action) return; if (!(postID = +request.responseJSON?.id)) return; const detail = { boardID, threadID, postID }; try { const { redirect, noko } = request.responseJSON; if (redirect && (originalNoko != null) && !originalNoko && !noko) { detail.redirect = redirect; } } catch (error) { } event = new CustomEvent('QRPostSuccessful', { bubbles: true, detail }); document.dispatchEvent(event); }); var originalNoko = window.tb_settings?.ajax?.always_noko_replies; let base; (((base = window.tb_settings || (window.tb_settings = {}))).ajax || (base.ajax = {})).always_noko_replies = true; }, setupCaptcha: ({ recaptchaKey }) => { const render = function () { const { classList } = document.documentElement; const container = document.querySelector('#qr .captcha-container'); if (!container) return; container.dataset.widgetID = window.grecaptcha.render(container, { sitekey: recaptchaKey, theme: classList.contains('tomorrow') || classList.contains('spooky') || classList.contains('dark-captcha') ? 'dark' : 'light', callback(response) { window.dispatchEvent(new CustomEvent('captcha:success', { detail: response })); } }); }; if (window.grecaptcha) { render(); } else { const cbNative = window.onRecaptchaLoaded; window.onRecaptchaLoaded = function () { render(); cbNative(); }; if (!document.head.querySelector('script[src^="https://www.google.com/recaptcha/api.js"]')) { const script = document.createElement('script'); script.src = 'https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoaded&render=explicit'; document.head.appendChild(script); } } }, resetCaptcha: () => { const container = document.querySelector('#qr .captcha-container'); if (!container) return; window.grecaptcha.reset(container.dataset.widgetID); }, setupTCaptcha: ({ boardID, threadID, autoLoad }) => { const { TCaptcha } = window; if (!TCaptcha?.init) { window.dispatchEvent(new CustomEvent('CreateNotification', { detail: { type: 'warning', content: 'Captcha unavailable. Reload the page and try again.' } })); return; } TCaptcha.init(document.querySelector('#qr .captcha-container'), boardID, +threadID); TCaptcha.setErrorCb((err) => window.dispatchEvent(new CustomEvent('CreateNotification', { detail: { type: 'warning', content: '' + err } }))); if (autoLoad === '1') TCaptcha.load(boardID, threadID); }, destroyTCaptcha: () => { window.TCaptcha.destroy(); }, TCaptchaClearChallenge: () => { window.TCaptcha.clearChallenge(); }, setupQR: () => { window.FCX.oekakiCB = () => window.Tegaki.flatten().toBlob(function (file) { const source = `oekaki-${Date.now()}`; window.FCX.oekakiLatest = source; document.dispatchEvent(new CustomEvent('QRSetFile', { bubbles: true, detail: { file, name: window.FCX.oekakiName, source } })); }); if (window.Tegaki) { const oekaki = document.querySelector('#qr .oekaki'); if (oekaki) oekaki.hidden = false; } }, qrTegakiDraw: () => { const { Tegaki, FCX } = window; const widthInput = document.querySelector('#qr [name=oekaki-width]'); const heightInput = document.querySelector('#qr [name=oekaki-height]'); const bgInput = document.querySelector('#qr [name=oekaki-bg]'); const bgColorInput = document.querySelector('#qr [name=oekaki-bgcolor]'); if (!widthInput || !heightInput || !bgInput || !bgColorInput) return; if (Tegaki.bg) { Tegaki.destroy(); } FCX.oekakiName = 'tegaki.png'; Tegaki.open({ onDone: FCX.oekakiCB, onCancel() { Tegaki.bgColor = '#ffffff'; }, width: +widthInput.value, height: +heightInput.value, bgColor: bgInput.checked ? bgColorInput.value : 'transparent' }); }, qrTegakiLoad: () => { const { Tegaki, FCX } = window; const name = document.getElementById('qr-filename').value.replace(/\.\w+$/, '') + '.png'; const source = document.getElementById('file-n-submit')?.dataset.source; const error = (content) => document.dispatchEvent(new CustomEvent('CreateNotification', { bubbles: true, detail: { type: 'warning', content, lifetime: 20 } })); var cb = function (e) { if (e) { this.removeEventListener('QRMetadata', cb, false); } const selected = document.getElementById('selected'); if (!selected?.dataset.type) return error('No file to edit.'); if (!/^(image|video)\//.test(selected.dataset.type)) { return error('Not an image.'); } const { width, height } = selected.dataset; if (!width || !height) return error('Metadata not available.'); if (height === 'loading') { selected.addEventListener('QRMetadata', cb, false); return; } if (Tegaki.bg) { Tegaki.destroy(); } FCX.oekakiName = name; Tegaki.open({ onDone: FCX.oekakiCB, onCancel() { Tegaki.bgColor = '#ffffff'; }, width: +width, height: +height, bgColor: 'transparent' }); const canvas = document.createElement('canvas'); canvas.width = (canvas.naturalWidth = +width); canvas.height = (canvas.naturalHeight = +height); canvas.hidden = true; document.body.appendChild(canvas); canvas.addEventListener('QRImageDrawn', function () { this.remove(); Tegaki.onOpenImageLoaded.call(this); }, false); canvas.dispatchEvent(new CustomEvent('QRDrawFile', { bubbles: true })); }; if (Tegaki.bg && (Tegaki.onDoneCb === FCX.oekakiCB) && (source === FCX.oekakiLatest)) { FCX.oekakiName = name; Tegaki.resume(); } else { cb(); } }, testNativeExtension: (output = {}) => { if (window.Parser?.postMenuIcon) output.enabled = 'true'; return output; }, }; const $ = ((selector, root = document.body) => root.querySelector(selector)); $.id = id => d.getElementById(id); $.ready = function (fc) { if (d.readyState !== 'loading') { $.queueTask(fc); return; } var cb = function () { $.off(d, 'DOMContentLoaded', cb); fc(); }; $.on(d, 'DOMContentLoaded', cb); }; $.formData = function (form) { if (form instanceof HTMLFormElement) { return new FormData(form); } const fd = new FormData(); for (var key in form) { var val = form[key]; if (val) { if ((typeof val === 'object') && 'newName' in val) { fd.append(key, val, val.newName); } else { fd.append(key, val); } } } return fd; }; $.extend = function (object, properties) { for (var key in properties) { var val = properties[key]; object[key] = val; } }; $.hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); $.getOwn = function (obj, key) { if (Object.prototype.hasOwnProperty.call(obj, key)) { return obj[key]; } else { return undefined; } }; $.ajax = (function () { let pageXHR = XMLHttpRequest; if (window.wrappedJSObject && !XMLHttpRequest.wrappedJSObject) { try { pageXHR = XPCNativeWrapper(window.wrappedJSObject.XMLHttpRequest); } catch (e) { } } return function (url, options = {}) { if (options.responseType == null) { options.responseType = 'json'; } if (!options.type) { options.type = (options.form && 'post') || 'get'; } url = url.replace(/^((?:https?:)?\/\/(?:\w+\.)?(?:4chan|4channel|4cdn)\.org)\/adv\//, '$1//adv/'); const { onloadend, timeout, responseType, withCredentials, type, onprogress, form, headers } = options; const r = new pageXHR(); try { r.open(type, url, true); const object = headers || {}; for (var key in object) { var value = object[key]; r.setRequestHeader(key, value); } $.extend(r, { onloadend, timeout, responseType, withCredentials }); $.extend(r.upload, { onprogress }); $.on(r, 'error', function () { if (!r.status) { c.warn(`${meta.name} failed to load: ${url}`); } }); r.send(form); } catch (err) { if (err.result !== 0x805e0006) { throw err; } r.onloadend = onloadend; $.queueTask($.event, 'error', null, r); $.queueTask($.event, 'loadend', null, r); } return r; }; })(); $.lastModified = dict(); $.whenModified = function (url, bucket, cb, options = {}) { let t; const { timeout, ajax } = options; const headers = dict(); if ((t = $.lastModified[bucket]?.[url]) != null) { headers['If-Modified-Since'] = t; } const r = (ajax || $.ajax)(url, { onloadend() { ($.lastModified[bucket] || ($.lastModified[bucket] = dict()))[url] = this.getResponseHeader('Last-Modified'); return cb.call(this); }, timeout, headers }); return r; }; (function () { const reqs = dict(); $.cache = function (url, cb, options = {}) { let req; const { ajax } = options; if (req = reqs[url]) { if (req.callbacks) { req.callbacks.push(cb); } else { $.queueTask(() => cb.call(req, { isCached: true })); } return req; } const onloadend = function () { if (!this.status) { delete reqs[url]; } for (cb of this.callbacks) { (cb => $.queueTask(() => cb.call(this, { isCached: false })))(cb); } return delete this.callbacks; }; req = (ajax || $.ajax)(url, { onloadend }); req.callbacks = [cb]; return reqs[url] = req; }; return $.cleanCache = function (testf) { for (var url in reqs) { if (testf(url)) { delete reqs[url]; } } }; })(); $.cb = { checked() { if ($.hasOwn(Conf, this.name)) { $.set(this.name, this.checked); return Conf[this.name] = this.checked; } }, value() { if ($.hasOwn(Conf, this.name)) { $.set(this.name, this.value.trim()); return Conf[this.name] = this.value; } } }; $.asap = function (test, cb) { if (test()) { return cb(); } else { return setTimeout($.asap, 25, test, cb); } }; $.onExists = function (root, selector, cb) { let el; if (el = $(selector, root)) { cb(el); return; } var observer = new MutationObserver(function () { if (el = $(selector, root)) { observer.disconnect(); cb(el); } }); observer.observe(root, { childList: true, subtree: true }); }; $.addStyle = function (css, id, test = 'head') { const style = $.el('style', { textContent: css }); if (id != null) { style.id = id; } $.onExists(doc, test, () => $.add(d.head, style)); return style; }; let cspNonce = null; $.getCSPNonce = function () { if (cspNonce == null) { const bytes = new Uint8Array(16); (self.crypto || window.crypto).getRandomValues(bytes); cspNonce = btoa(String.fromCharCode(...bytes)); } return cspNonce; }; $.addCSP = function (policy) { const meta = $.el('meta', { httpEquiv: 'Content-Security-Policy', content: policy }); if (d.head) { $.add(d.head, meta); return $.rm(meta); } else { const head = $.add((doc || d), $.el('head')); $.add(head, meta); return $.rm(head); } }; $.x = function (path, root) { if (!root) { root = d.body; } return d.evaluate(path, root, null, 8, null).singleNodeValue; }; $.X = function (path, root) { if (!root) { root = d.body; } return d.evaluate(path, root, null, 7, null); }; $.addClass = function (el, ...classNames) { el.classList.add(...classNames); }; $.rmClass = function (el, ...classNames) { el.classList.remove(...classNames); }; $.toggleClass = (el, className) => el.classList.toggle(className); $.hasClass = (el, className) => el.classList.contains(className); $.rm = el => el?.remove(); $.rmAll = root => root.textContent = null; $.tn = s => d.createTextNode(s); $.frag = () => d.createDocumentFragment(); $.nodes = function (nodes) { if (!(nodes instanceof Array)) { return nodes; } const frag = $.frag(); for (var node of nodes) { frag.appendChild(node); } return frag; }; $.add = (parent, el) => parent.appendChild($.nodes(el)); $.prepend = (parent, el) => parent.insertBefore($.nodes(el), parent.firstChild); $.after = (root, el) => root.parentNode.insertBefore($.nodes(el), root.nextSibling); $.before = (root, el) => root.parentNode.insertBefore($.nodes(el), root); $.replace = (root, el) => root.parentNode.replaceChild($.nodes(el), root); $.el = function (tag, properties, properties2) { const el = d.createElement(tag); if (properties) $.extend(el, properties); if (properties2) $.extend(el, properties2); return el; }; $.on = function (el, events, handler) { for (var event of events.split(' ')) { el.addEventListener(event, handler, false); } }; $.off = function (el, events, handler) { for (var event of events.split(' ')) { el.removeEventListener(event, handler, false); } }; d.addEventListener('click', function (e) { const anchor = e.target?.closest?.('a[href]'); const href = anchor?.getAttribute('href') || ''; if (/^javascript:\s*;?\s*$/i.test(href)) { e.preventDefault(); } }, true); $.one = function (el, events, handler) { var cb = function (e) { $.off(el, events, cb); return handler.call(this, e); }; return $.on(el, events, cb); }; $.event = function (event, detail, root = d) { if (!globalThis.chrome?.extension) { if ((detail != null) && (typeof cloneInto === 'function')) { detail = cloneInto(detail, d.defaultView); } } return root.dispatchEvent(new CustomEvent(event, { bubbles: true, cancelable: true, detail })); }; (function () { if (!/PaleMoon\//.test(navigator.userAgent) || (+GM_info?.version?.split('.')[0] < 2) || (typeof cloneInto !== 'undefined')) { return; } try { return new CustomEvent('x', { detail: {} }); } catch (err) { const unsafeConstructors = { Object: unsafeWindow.Object, Array: unsafeWindow.Array }; var clone = function (obj) { let constructor; if ((obj != null) && (typeof obj === 'object') && (constructor = unsafeConstructors[obj.constructor.name])) { const obj2 = new constructor(); for (var key in obj) { var val = obj[key]; obj2[key] = clone(val); } return obj2; } else { return obj; } }; return $.event = (event, detail, root = d) => root.dispatchEvent(new CustomEvent(event, { bubbles: true, cancelable: true, detail: clone(detail) })); } })(); $.modifiedClick = e => e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || (e.button !== 0); if (!globalThis.chrome?.extension) { $.open = (GM?.openInTab != null) ? GM.openInTab : (typeof GM_openInTab !== 'undefined' && GM_openInTab !== null) ? GM_openInTab : url => window.open(url, '_blank'); } else { $.open = url => window.open(url, '_blank'); } $.debounce = function (wait, fn) { let lastCall = 0; let timeout = null; let that = null; let args = null; const exec = function () { lastCall = Date.now(); return fn.apply(that, args); }; return function () { args = arguments; that = this; if (lastCall < (Date.now() - wait)) { return exec(); } if (timeout !== null) clearTimeout(timeout); return timeout = setTimeout(exec, wait); }; }; $.queueTask = (function () { const taskQueue = []; const execTask = function () { const task = taskQueue.shift(); if (!task) return; const [func, ...args] = Array.from(task); func(...args); }; return function () { taskQueue.push(arguments); Promise.resolve().then(execTask); }; })(); $.global = async function (fn, data) { if (platform === 'crx' && chrome.runtime.getManifest().manifest_version === 3) { return $.eventPageRequest({ type: 'runInPageContext', fn, data }); } else { if (doc) { const script = $.el('script', { textContent: `(${PageContextFunctions[fn]})(document.currentScript.dataset);` }); script.nonce = $.getCSPNonce(); if (data) { $.extend(script.dataset, data); } $.add((d.head || doc), script); $.rm(script); return script.dataset; } else { try { PageContextFunctions[fn](data); } catch (error) { console.error(error); } return data; } } }; $.bytesToString = function (size) { let unit = 0; while (size >= 1024) { size /= 1024; unit++; } size = unit > 1 ? Math.round(size * 100) / 100 : Math.round(size); return `${size} ${['B', 'KB', 'MB', 'GB'][unit]}`; }; $.minmax = (value, min, max) => value < min ? min : value > max ? max : value; $.hasAudio = video => video.mozHasAudio || !!video.webkitAudioDecodedByteCount || video.nextElementSibling?.tagName === 'AUDIO'; $.luma = rgb => (rgb[0] * 0.299) + (rgb[1] * 0.587) + (rgb[2] * 0.114); $.unescape = function (text) { if (text == null) { return text; } return text.replace(/<[^>]*>/g, '').replace(/&(amp|#039|quot|lt|gt|#44);/g, (c) => ({ '&': '&', ''': "'", '"': '"', '<': '<', '>': '>', ',': ',' }[c])); }; $.isImage = url => /\.(jpe?g|jfif|png|gif|bmp|webp|avif|jxl)$/i.test(url); $.isVideo = url => /\.(webm|mp4|ogv)$/i.test(url); $.engine = (function () { if (/Edge\//.test(navigator.userAgent)) { return 'edge'; } if (/Chrome\//.test(navigator.userAgent)) { return 'blink'; } if (/WebKit\//.test(navigator.userAgent)) { return 'webkit'; } if (/Gecko\/|Goanna/.test(navigator.userAgent)) { return 'gecko'; } return 'unknown'; })(); $.hasStorage = (function () { try { if (localStorage.getItem(g.NAMESPACE + 'hasStorage') === 'true') { return true; } localStorage.setItem(g.NAMESPACE + 'hasStorage', 'true'); return localStorage.getItem(g.NAMESPACE + 'hasStorage') === 'true'; } catch (error) { return false; } })(); $.item = function (key, val) { const item = dict(); item[key] = val; return item; }; $.oneItemSugar = fn => (function (key, val, cb) { if (typeof key === 'string') { return fn($.item(key, val), cb); } else { return fn(key, val); } }); $.syncing = dict(); $.securityCheck = function (data) { if (location.protocol !== 'https:') { return delete data['Redirect to HTTPS']; } }; if ((GM?.deleteValue != null) && window.BroadcastChannel && (typeof GM_addValueChangeListener === 'undefined' || GM_addValueChangeListener === null)) { $.syncChannel = new BroadcastChannel(g.NAMESPACE + 'sync'); $.on($.syncChannel, 'message', e => (() => { const result = []; for (var key in e.data) { var cb; var val = e.data[key]; if (cb = $.syncing[key]) { result.push(cb(dict.json(JSON.stringify(val)), key)); } } return result; })()); $.sync = (key, cb) => $.syncing[key] = cb; $.forceSync = function () { }; $.delete = function (keys, cb) { let key; if (!(keys instanceof Array)) { keys = [keys]; } Promise.all(keys.map(key => GM.deleteValue(g.NAMESPACE + key))).then(function () { const items = dict(); for (key of keys) items[key] = undefined; $.syncChannel.postMessage(items); cb?.(); }); }; $.get = $.oneItemSugar(function (items, cb) { const keys = Object.keys(items); return Promise.all(keys.map((key) => GM.getValue(g.NAMESPACE + key))).then(function (values) { for (let i = 0; i < values.length; i++) { var val = values[i]; if (val) { items[keys[i]] = dict.json(val); } } return cb(items); }); }); $.set = $.oneItemSugar(function (items, cb) { $.securityCheck(items); return Promise.all((() => { const result = []; for (var key in items) { var val = items[key]; result.push(GM.setValue(g.NAMESPACE + key, JSON.stringify(val))); } return result; })()).then(function () { $.syncChannel.postMessage(items); return cb?.(); }); }); $.clear = cb => GM.listValues().then(keys => $.delete(keys.map(key => key.replace(g.NAMESPACE, '')), cb)).catch(() => $.delete(Object.keys(Conf).concat(['previousversion', 'QR Size', 'QR.persona']), cb)); } else { if (typeof GM_deleteValue !== 'undefined' && GM_deleteValue !== null) { $.getValue = GM_getValue; $.listValues = () => GM_listValues(); } else if ($.hasStorage) { $.getValue = key => localStorage.getItem(key); $.listValues = () => (() => { const result = []; for (var key in localStorage) { if (key.slice(0, g.NAMESPACE.length) === g.NAMESPACE) { result.push(key); } } return result; })(); } else { $.getValue = function () { }; $.listValues = () => []; } if (typeof GM_addValueChangeListener !== 'undefined' && GM_addValueChangeListener !== null) { $.setValue = GM_setValue; $.deleteValue = GM_deleteValue; } else if (typeof GM_deleteValue !== 'undefined' && GM_deleteValue !== null) { $.oldValue = dict(); $.setValue = function (key, val) { GM_setValue(key, val); if (key in $.syncing) { $.oldValue[key] = val; if ($.hasStorage) { return localStorage.setItem(key, val); } } }; $.deleteValue = function (key) { GM_deleteValue(key); if (key in $.syncing) { delete $.oldValue[key]; if ($.hasStorage) { return localStorage.removeItem(key); } } }; if (!$.hasStorage) { $.cantSync = true; } } else if ($.hasStorage) { $.oldValue = dict(); $.setValue = function (key, val) { if (key in $.syncing) { $.oldValue[key] = val; } return localStorage.setItem(key, val); }; $.deleteValue = function (key) { if (key in $.syncing) { delete $.oldValue[key]; } return localStorage.removeItem(key); }; } else { $.setValue = function () { }; $.deleteValue = function () { }; $.cantSync = ($.cantSet = true); } if (typeof GM_addValueChangeListener !== 'undefined' && GM_addValueChangeListener !== null) { $.sync = (key, cb) => $.syncing[key] = GM_addValueChangeListener(g.NAMESPACE + key, function (key2, oldValue, newValue, remote) { if (remote) { if (newValue !== undefined) { newValue = dict.json(newValue); } return cb?.(newValue, key); } }); $.forceSync = function () { }; } else if ((typeof GM_deleteValue !== 'undefined' && GM_deleteValue !== null) || $.hasStorage) { $.sync = function (key, cb) { key = g.NAMESPACE + key; $.syncing[key] = cb; return $.oldValue[key] = $.getValue(key); }; (function () { const onChange = function ({ key, newValue }) { let cb; if (!(cb = $.syncing[key])) { return; } if (newValue != null) { if (newValue === $.oldValue[key]) { return; } $.oldValue[key] = newValue; return cb(dict.json(newValue), key.slice(g.NAMESPACE.length)); } else { if ($.oldValue[key] == null) { return; } delete $.oldValue[key]; return cb(undefined, key.slice(g.NAMESPACE.length)); } }; $.on(window, 'storage', onChange); return $.forceSync = function (key) { key = g.NAMESPACE + key; return onChange({ key, newValue: $.getValue(key) }); }; })(); } else { $.sync = function () { }; $.forceSync = function () { }; } $.delete = function (keys) { if (!(keys instanceof Array)) { keys = [keys]; } for (var key of keys) { $.deleteValue(g.NAMESPACE + key); } }; $.get = $.oneItemSugar((items, cb) => $.queueTask($.getSync, items, cb)); $.getSync = function (items, cb) { for (var key in items) { var val2; if (val2 = $.getValue(g.NAMESPACE + key)) { try { items[key] = dict.json(val2); } catch (err) { if (!/^(?:undefined)*$/.test(val2)) { throw err; } } } } return cb(items); }; $.set = $.oneItemSugar(function (items, cb) { $.securityCheck(items); return $.queueTask(function () { for (var key in items) { var value = items[key]; $.setValue(g.NAMESPACE + key, JSON.stringify(value)); } return cb?.(); }); }); $.clear = function (cb) { $.delete(Object.keys(Conf)); $.delete(['previousversion', 'QR Size', 'QR.persona']); try { $.delete($.listValues().map(key => key.replace(g.NAMESPACE, ''))); } catch (error) { } return cb?.(); }; } var Get = { url(type, IDs, ...args) { let f, site; if ((site = g.sites[IDs.siteID]) && (f = $.getOwn(site.urls, type))) { return f(IDs, ...args); } else { return undefined; } }, threadExcerpt(thread) { const { OP } = thread; const excerpt = (`/${decodeURIComponent(thread.board.ID)}/ - `) + (OP.info.subject?.trim() || OP.commentDisplay().replace(/\n+/g, ' // ') || OP.file?.name || `No.${OP}`); if (excerpt.length > 73) { return `${excerpt.slice(0, 70)}...`; } return excerpt; }, threadFromRoot(root) { if (root == null) { return null; } const { board } = root.dataset; return g.threads.get(`${board ? encodeURIComponent(board) : g.BOARD.ID}.${root.id.match(/\d*$/)[0]}`); }, threadFromNode(node) { return Get.threadFromRoot($.x(`ancestor-or-self::${g.SITE.xpath.thread}`, node)); }, postFromRoot(root) { if (root == null) { return null; } const post = g.posts.get(root.dataset.fullID); const index = root.dataset.clone; if (index) { return post.clones[+index]; } else { return post; } }, postFromNode(root) { return Get.postFromRoot($.x(`ancestor-or-self::${g.SITE.xpath.postContainer}[1]`, root)); }, postDataFromLink(link) { let boardID, postID, threadID; if (link.dataset.postID) { ({ boardID, threadID, postID } = link.dataset); if (!threadID) { threadID = 0; } } else { const match = link.href.match(g.SITE.regexp.quotelink); [boardID, threadID, postID] = match.slice(1); if (!postID) { postID = threadID; } } return { boardID, threadID: +threadID, postID: +postID }; }, allQuotelinksLinkingTo(post) { const quotelinks = []; const posts = g.posts; const { fullID } = post; const handleQuotes = function (qPost, type) { quotelinks.push(...(qPost.nodes[type] || [])); for (var clone of qPost.clones) { quotelinks.push(...(clone.nodes[type] || [])); } }; posts.forEach(function (qPost) { if (qPost.quotes.includes(fullID)) { return handleQuotes(qPost, 'quotelinks'); } }); if (Conf['Quote Backlinks']) { for (var quote of post.quotes) { var qPost; if ((qPost = posts.get(quote))) { handleQuotes(qPost, 'backlinks'); } } } return quotelinks.filter(function (quotelink) { const { boardID, postID } = Get.postDataFromLink(quotelink); return (boardID === post.board.ID) && (postID === post.ID); }); } }; const isEscaped = Symbol('isEscaped'); const voidElements = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr',]); const hFragment = Symbol('hFragment'); function h(tag, attributes, ...children) { let innerHTML = tag === hFragment ? '' : `<${tag}`; if (attributes) { for (const [attribute, value] of Object.entries(attributes)) { if (!value && value !== 0) continue; innerHTML += ` ${attribute}`; if (value === true) continue; innerHTML += `="${E(value.toString())}"`; } } if (tag !== hFragment) innerHTML += '>'; const isVoid = tag !== hFragment && voidElements.has(tag); if (isVoid) { if (children.length) throw new TypeError(`${tag} is a void html element and can't have child elements`); } else { for (const child of children) { if (child === null || child === undefined || child === '') continue; if (child instanceof Object && "innerHTML" in child && child[isEscaped]) { innerHTML += child.innerHTML; continue; } innerHTML += E(child.toString()); } } if (!isVoid && tag !== hFragment) innerHTML += ``; return { innerHTML, [isEscaped]: true }; } const iconSetData = { "fontAwesome": { "image": "", "eye": "", "expand": "", "comment": "", "circleQuestion": "", "refresh": "", "wrench": "", "bolt": "", "link": "", "pencil": "", "clipboard": "", "clock": "", "shuffle": "", "undo": "", "download": "", "bookOpen": "", "shrink": "", "heart": "", "caretRight": "", "caretLeft": "", "caretDown": "", "scissors": "", "xmark": "", "check": "", "arrowRightLong": "", "plus": "", "squarePlus": "", "squareMinus": "", "play": "", "stop": "", "arrowUpLong": "", "arrowDownLong": "", "bars": "", "barsStaggered": "", "sliders": "", "film": "", "grid": "", "magnifyingGlass": "" }, "lucide": { "image": "\n \n ", "eye": "\n ", "expand": "\n \n \n ", "comment": "", "circleQuestion": "\n \n ", "refresh": "\n \n \n ", "wrench": "", "bolt": "", "link": "\n ", "pencil": "\n ", "clipboard": "\n ", "clock": "\n ", "shuffle": "\n \n \n \n ", "undo": "\n ", "download": "\n \n ", "bookOpen": "\n ", "shrink": "\n \n \n ", "heart": "", "caretRight": "", "caretLeft": "", "caretDown": "", "scissors": "\n \n \n \n ", "xmark": "\n ", "check": "", "arrowRightLong": "\n ", "plus": "\n ", "squarePlus": "\n \n ", "squareMinus": "\n ", "play": "", "stop": "", "arrowUpLong": "\n ", "arrowDownLong": "\n ", "bars": "\n \n ", "barsStaggered": "\n \n ", "sliders": "\n \n \n \n \n \n \n \n ", "film": "\n \n \n \n \n \n \n ", "grid": "\n \n \n ", "magnifyingGlass": "\n " }, "material": { "image": "", "eye": "", "expand": "", "comment": "", "circleQuestion": "", "refresh": "", "wrench": "", "bolt": "", "link": "", "pencil": "", "clipboard": "", "clock": "", "shuffle": "", "undo": "", "download": "", "bookOpen": "", "shrink": "", "heart": "", "caretRight": "", "caretLeft": "", "caretDown": "", "scissors": "", "xmark": "", "check": "", "arrowRightLong": "", "plus": "", "squarePlus": "", "squareMinus": "", "play": "", "stop": "", "arrowUpLong": "", "arrowDownLong": "", "bars": "", "barsStaggered": "", "sliders": "", "film": "", "grid": "", "magnifyingGlass": "" }, "phosphor": { "image": "", "eye": "", "expand": "", "comment": "", "circleQuestion": "", "refresh": "", "wrench": "", "bolt": "", "link": "", "pencil": "", "clipboard": "", "clock": "", "shuffle": "", "undo": "", "download": "", "bookOpen": "", "shrink": "", "heart": "", "caretRight": "", "caretLeft": "", "caretDown": "", "scissors": "", "xmark": "", "check": "", "arrowRightLong": "", "plus": "", "squarePlus": "", "squareMinus": "", "play": "", "stop": "", "arrowUpLong": "", "arrowDownLong": "", "bars": "", "barsStaggered": "", "sliders": "", "film": "", "grid": "", "magnifyingGlass": "" }, "tabler": { "image": "\n \n \n \n ", "eye": "\n \n ", "expand": "\n \n \n \n \n \n \n \n ", "comment": "\n ", "circleQuestion": "\n \n \n ", "refresh": "\n \n ", "wrench": "\n ", "bolt": "\n ", "link": "\n \n \n ", "pencil": "\n \n ", "clipboard": "\n \n ", "clock": "\n \n ", "shuffle": "\n \n \n \n ", "undo": "\n \n ", "download": "\n \n \n ", "bookOpen": "\n \n \n \n \n ", "shrink": "\n \n \n \n \n \n \n \n ", "heart": "\n ", "caretRight": "\n ", "caretLeft": "\n ", "caretDown": "\n ", "scissors": "\n \n \n \n ", "xmark": "\n \n ", "check": "\n ", "arrowRightLong": "\n \n \n ", "plus": "\n \n ", "squarePlus": "\n \n \n ", "squareMinus": "\n \n ", "play": "\n ", "stop": "\n ", "arrowUpLong": "\n \n \n ", "arrowDownLong": "\n \n \n ", "bars": "\n \n \n ", "barsStaggered": "\n \n \n \n \n \n ", "sliders": "\n \n \n \n \n \n \n \n \n ", "film": "\n \n \n \n \n \n \n \n ", "grid": "\n \n \n \n ", "magnifyingGlass": "\n \n " }, "bootstrap": { "image": "", "eye": "\n ", "expand": "", "comment": "", "circleQuestion": "", "refresh": "\n ", "wrench": "", "bolt": "", "link": "\n ", "pencil": "", "clipboard": "", "clock": "", "shuffle": "\n ", "undo": "\n ", "download": "\n ", "bookOpen": "", "shrink": "", "heart": "", "caretRight": "", "caretLeft": "", "caretDown": "", "scissors": "", "xmark": "", "check": "", "arrowRightLong": "", "plus": "", "squarePlus": "", "squareMinus": "", "play": "", "stop": "", "arrowUpLong": "", "arrowDownLong": "", "bars": "", "barsStaggered": "", "sliders": "", "film": "", "grid": "", "magnifyingGlass": "" }, "heroicons": { "image": "", "eye": "\n ", "expand": "", "comment": "", "circleQuestion": "", "refresh": "", "wrench": "", "bolt": "", "link": "", "pencil": "", "clipboard": "", "clock": "", "shuffle": "", "undo": "", "download": "", "bookOpen": "", "shrink": "", "heart": "", "caretRight": "", "caretLeft": "", "caretDown": "", "scissors": "\n ", "xmark": "", "check": "", "arrowRightLong": "", "plus": "", "squarePlus": "", "squareMinus": "", "play": "", "stop": "", "arrowUpLong": "", "arrowDownLong": "", "bars": "", "barsStaggered": "", "sliders": "", "film": "", "grid": "", "magnifyingGlass": "" }, "ionicons": { "image": "", "eye": "", "expand": "", "comment": "", "circleQuestion": "", "refresh": "", "wrench": "", "bolt": "", "link": "", "pencil": "", "clipboard": "", "clock": "", "shuffle": "", "undo": "", "download": "", "bookOpen": "", "shrink": "", "heart": "", "caretRight": "", "caretLeft": "", "caretDown": "", "scissors": "", "xmark": "", "check": "", "arrowRightLong": "", "plus": "", "squarePlus": "", "squareMinus": "", "play": "", "stop": "", "arrowUpLong": "", "arrowDownLong": "", "bars": "", "barsStaggered": "", "sliders": "", "film": "", "grid": "", "magnifyingGlass": "" } }; const ICON_SETS = [ { id: 'fontAwesome', name: 'Font Awesome' }, { id: 'lucide', name: 'Lucide' }, { id: 'material', name: 'Material Icons' }, { id: 'phosphor', name: 'Phosphor' }, { id: 'tabler', name: 'Tabler' }, { id: 'bootstrap', name: 'Bootstrap Icons' }, { id: 'heroicons', name: 'Heroicons' }, { id: 'ionicons', name: 'Ionicons' }, ]; const DEFAULT_ICON_SET = 'fontAwesome'; const isIconSet = (value) => typeof value === 'string' && Object.prototype.hasOwnProperty.call(iconSetData, value); let currentSet = DEFAULT_ICON_SET; const lookup = (name) => { const html = iconSetData[currentSet][name] ?? iconSetData[DEFAULT_ICON_SET][name]; if (!html) throw new Error(`Icon "${name}" not found.`); return html; }; var Icon = { setIconSet(set) { currentSet = isIconSet(set) ? set : DEFAULT_ICON_SET; }, set(node, name, altText) { const html = lookup(name); if (altText) { node.innerHTML = `${E(altText)}${html}`; } else { node.innerHTML = html; } }, get(name) { return lookup(name); }, raw(name) { return { innerHTML: lookup(name), [isEscaped]: true }; }, }; const settingsHtml = () => h("div", { id: "fourchanx-settings", class: "dialog" }, h("div", { class: "settings-titlebar move" }, h("button", { type: "button", class: "settings-nav-scroll settings-nav-scroll-left", "aria-label": "Scroll sections left", title: "Previous sections" }, Icon.raw('caretLeft')), h("button", { type: "button", class: "settings-nav-scroll settings-nav-scroll-right", "aria-label": "Scroll sections right", title: "More sections" }, Icon.raw('caretRight')), h("span", { class: "settings-titlebar-actions" }, h("a", { href: "#", class: "settings-nav-toggle", role: "button", "aria-expanded": "false", "aria-label": "Settings sections", title: "Settings sections" }, Icon.raw('bars')), h("a", { href: "#", class: "accordion-toggle", role: "button", "aria-pressed": "false", "aria-label": "Accordion mode", title: "Accordion: keep only one section open at a time" }, Icon.raw('barsStaggered')), h("a", { href: "#", class: "expand-all", "aria-label": "Expand all sections", title: "Expand all sections" }, Icon.raw('squarePlus')), h("a", { href: "#", class: "collapse-all", "aria-label": "Collapse all sections", title: "Collapse all sections" }, Icon.raw('squareMinus')), h("a", { href: "#", class: "close", title: "Close" }, "\u2715"))), h("div", { class: "settings-body" }, h("nav", null, h("div", { class: "settings-search" }, h("input", { type: "search", class: "field", placeholder: "Search", autocomplete: "off" })), h("div", { class: "sections-list" })), h("div", { class: "section-container" }, h("section", null))), h("div", { class: "settings-footer" }, h("div", { class: "settings-footer-menu" }, h("button", { type: "button", class: "settings-footer-menu-toggle", "aria-expanded": "false", "aria-label": "Settings actions", title: "Settings actions" }, Icon.raw('sliders')), h("div", { class: "settings-actions" }, h("a", { href: "#", class: "export" }, "Export"), h("a", { href: "#", class: "import" }, "Import"), h("a", { href: "#", class: "reset" }, "Reset Settings"), h("label", { class: "highlight-next-toggle", title: "Mark settings added by neXT. Green = new, orange = changed." }, h("input", { id: "settings-highlight-next", type: "checkbox", autocomplete: "off" }), h("span", null, "Highlight neXT")), h("label", { class: "remember-layout-toggle", title: "Remember dialog layout and section collapse state" }, h("input", { id: "settings-remember-layout", type: "checkbox", autocomplete: "off" }), h("span", null, "Remember layout")), h("input", { type: "file", hidden: true, accept: ".json,application/json" }))), h("p", { class: "imp-exp-result warning" }), h("div", { class: "credits" }, h("a", { href: meta.page, target: "_blank" }, meta.name), h("a", { href: meta.changelog, target: "_blank" }, g.VERSION), h("a", { href: meta.userGuide, target: "_blank" }, "User Guide"), h("a", { href: meta.issues, target: "_blank" }, "Issues")))); var FilterGuidePage = `
Filter is disabled.

Use regular expressions, one per line.
Lines starting with a # will be ignored.
For example, /weeaboo/i will filter posts containing the string \`weeaboo\`, case-insensitive.
MD5 and Unique ID filtering use exact string matching, not regular expressions.

    You can use these settings with each regular expression, separate them with semicolons:
  • Per boards, separate them with commas. It is global if not specified. Use sfw and nsfw to reference all worksafe or not-worksafe boards.
    For example: boards:a,jp;.
    To specify boards on a particular site, put the beginning of the domain and a slash character before the list.
    Any initial www. should not be included, and all 4chan domains are considered 4chan.org.
    For example: boards:4:a,jp,sama:a,z;.
    An asterisk can be used to specify all boards on a site.
    For example: boards:4:*;.
  • Show a custom reason in the stub. If not given, the type and regex will be shown.
    For example: reason:This is bait;
  • Select boards to be excluded from the filter. The syntax is the same as for the boards: option above.
    For example: exclude:vg,v;.
  • Filter OPs only along with their threads (\`only\`) or replies only (\`no\`).
    For example: op:only; or op:no;.
  • Filter only posts with files (\`only\`) or only posts without files (\`no\`).
    For example: file:only; or file:no;.
  • Overrule the \`Show Stubs\` setting if specified: create a stub (\`yes\`) or not (\`no\`).
    For example: stub:yes; or stub:no;.
  • Highlight instead of hiding. You can specify a class name to use with a userstyle.
    For example: highlight; or highlight:wallpaper;.
  • Glow the whole catalog entry (thumbnail and text) instead of just the thumbnail. Only affects highlight rules in the catalog; ignored in thread view.
    For example: /4chan X/i;highlight;tile.
  • Highlighted OPs will have their threads put on top of the board index by default.
    For example: top:yes; or top:no;.
  • Whitelist a thread against hide filters. When a highlight rule matches with override, the thread stays visible and highlighted even if another rule would normally hide it.
    Only applies to highlight rules; ignored on hide and notify rules.
    For example: /4chan X/i;boards:g;highlight;override keeps any /g/ thread whose subject mentions "4chan X" visible even if its OP body matches a hide filter elsewhere in your list.
  • Show a desktop notification instead of hiding: notify;.
  • Hide all posts from this poster: poster;.
  • Recursively hide replies: replies;.
  • Hide anyway even if highlighting. The highlight class will be applied to the stub: hide;.
  • Filters in the "General" section apply to multiple fields, by default subject,name,filename,comment.
    The fields can be specified with the type option, separated by commas.
    For example: .
    Types can also be combined with a + sign; this indicates the filter applies to the given fields joined by newlines.
    For example: type:filename+filesize+dimensions;.
`; var SaucePage = `
Sauce is disabled.
Parameter guide
These parameters will be replaced by their corresponding values in the URL and displayed text:
  • %IMG: Full image URL for GIF, JPG, and PNG; thumbnail URL for other types.
  • %URL: Full image URL.
  • %TURL: Thumbnail URL.
  • %name: Original file name.
  • %board: Current board.
  • %MD5: MD5 hash in base64.
  • %sMD5: MD5 hash in base64 using - and _.
  • %hMD5: MD5 hash in hexadecimal.
  • %$0: Matched regular expression within the filename.
  • %$1, %$2, %$3, ... : Subexpressions within the matched regular expression.
  • %%, %semi: Literal % and ;.
Lines starting with a # will be ignored.
You can specify a display text by appending ;text:[text] to the URL.
You can specify the applicable boards/sites by appending ;boards:[board1],[board2]. See the Filter guide for details.
You can specify the applicable file types by appending ;types:[extension1],[extension2].
You can specify a regular expression the filename must match by appending ;regexp:[regular expression].
`; var AdvancedPage = `
Archives
404 Redirect is disabled.
Thread redirection Thread fetching Post fetching File redirection

Archive Lists: Each line below should be an archive list in this format or a URL to load an archive list from.
Archive properties can be overriden by another item with the same uid (or if absent, its name).
Last updated:
External Catalog
External Catalog is disabled. This will be used only as a fallback.
URLs of external catalog sites, where %board is to be replaced by the board name.
Each URL should be followed by ;boards: and optionally ;exclude: and a list of supported/excluded boards in the format explained in the Filter guide.
Override 4chan Image Host
Change 4chan image links to this domain. Leave blank for no change.
Captcha Language
Choose from list of language codes. Leave blank to autoselect.
Time Formatting is disabled, relative time setting still applies.
:
Day: %a, %A, %d, %e
Month: %m, %b, %B, %n (Not zero padded)
Year: %y, %Y
Hour: %k, %H, %l, %I, %p, %P
Minute: %M
Second: %S
Literal %: %%
Quote Backlinks formatting is disabled.
:
Default pasted content filename
.png
File Info Formatting is disabled.
:
Link: %l (truncated), %L (untruncated), %T (4chan filename)
Filename: %n (truncated), %N (untruncated), %t (4chan filename)
Download button: %d
Quick filter MD5: %f
Spoiler indicator: %p
Size: %B (Bytes), %K (KB), %M (MB), %s (4chan default)
Resolution: %r (Displays 'PDF' for PDF files)
Tag: %g
Literal %: %%
Quick Reply Personas
Usage guide

One item per line.
Items will be added in the relevant input's auto-completion list.
Password items will always be used, since there is no password input.
Lines starting with a # will be ignored.

    You can use these settings with each item, separate them with semicolons:
  • Possible items are: name, options (or equivalently email), subject and password.
  • Wrap values of items with quotes, like this: options:"sage".
  • Force values as defaults with the always keyword, for example: options:"sage";always.
  • Select specific boards for an item, separated with commas, for example: options:"sage";boards:jp;always.
Thread Updater is disabled.
Interval: seconds
Custom Cooldown Time
Seconds:
Unread Favicon
Known Banners
List of known banners, used for click-to-change feature.
Javascript Whitelist
Sources from which Javascript is allowed to be loaded by Content Security Policy.
Lines starting with a # will be ignored. Remove or comment out all lines to allow everything.
X (formerly Twitter) embeds
Only applies if embeds are enabled in the main settings



Thread updater sound
100%

Sound library

Radio marks the default sound

Board overrides

Post overrides

Auto-removed when threads 404
`; var KeybindsPage = `
Keybinds are disabled.
Allowed keys: a-z, 0-9, Ctrl, Shift, Alt, Meta, Enter, Esc, Up, Down, Right, Left.
Press Backspace to disable a keybind.
Actions marked with * are click modifiers: hold the keys and click.
ActionsKeybinds
`; var FilterSelectPage = `
`; var SimpleFiltersPage = `
`; var StylingPage = `
Site Style
Highlights

Each row is one highlight state. Click a row to open its colors and scrollbar marker; only one stays open at a time. Each row shows a live preview of its style. The master toggles turn whole groups on or off.

Your post
Text colors
Highlight background
Edge widthpx
Edge style
Opacity
Match highlight color
Marker color
Marker opacity
Live Preview
Quotes you
Text colors
Highlight background
Edge widthpx
Edge style
Opacity
Match highlight color
Marker color
Marker opacity
Live Preview
Ghost post
Text colors
Highlight background
Edge widthpx
Edge style
Opacity
Match highlight color
Marker color
Marker opacity
Live Preview
Unread line scrollbar only
Opacity
Your post
Highlight location
Text colors
Highlight background
Border widthpx
Border style
Opacity
Glow
Glow intensity
Live Preview
Watched thread
Highlight location
Text colors
Highlight background
Border widthpx
Border style
Opacity
Glow
Glow intensity
Live Preview
Filtered thread
Highlight location
Text colors
Highlight background
Border widthpx
Border style
Opacity
Glow
Glow intensity
Live Preview
Color tools
Palette manager tools for the colors above
Text Colors

Defaults keeps the theme's own text, link and greentext colors. Auto chooses readable text/link colors from the active page background. Switch to Manual to set your own values.

Custom CSS
For more information about customizing 4chan-neXT's CSS, see the styling guide.
`; const $$ = (selector, root = d.body) => Array.from(root.querySelectorAll(selector)); var ImageHost = { init() { if ((!(this.useFaster = /\S/.test(Conf['fourchanImageHost']))) || (g.SITE.software !== 'yotsuba') || (g.VIEW !== 'index' && g.VIEW !== 'thread')) { return; } return Callbacks.Post.push({ name: 'Image Host Rewriting', cb: this.node }); }, suggestions: ['i.4cdn.org', 'is2.4chan.org'], host() { return Conf['fourchanImageHost'].trim() || 'i.4cdn.org'; }, flashHost() { return 'i.4cdn.org'; }, thumbHost() { return 'i.4cdn.org'; }, test(hostname) { return (hostname === 'i.4cdn.org') || ImageHost.regex.test(hostname); }, regex: /^is\d*\.4chan(?:nel)?\.org$/, node() { if (this.isClone) { return; } const host = ImageHost.host(); if (this.file && ImageHost.test(this.file.url.split('/')[2]) && !/\.swf$/.test(this.file.url)) { this.file.link.hostname = host; if (this.file.thumbLink) { this.file.thumbLink.hostname = host; } this.file.url = this.file.link.href; } return ImageHost.fixLinks($$('a', this.nodes.comment)); }, fixLinks(links) { for (var link of links) { if (ImageHost.test(link.hostname) && !/\.swf$/.test(link.pathname)) { var host = ImageHost.host(); if (link.hostname !== host) { link.hostname = host; } } } } }; var QuickReplyPage = `
+
`; const PHONE_MAX_SCREEN = 600; function detectMobileDevice(win = window) { try { const coarse = win.matchMedia('(pointer: coarse)').matches; const touch = (win.navigator.maxTouchPoints ?? 0) > 0; const small = Math.min(win.screen.width, win.screen.height) <= PHONE_MAX_SCREEN; return coarse && touch && small; } catch (error) { return false; } } function resolveMobileLayout(setting, detected) { if (setting === 'on') { return true; } if (setting === 'off') { return false; } return detected; } function applyMobileLayout(setting, root, win = window) { const enabled = resolveMobileLayout(setting, detectMobileDevice(win)); root.classList.toggle('xt-mobile', enabled); return enabled; } const BAR_LAYOUT_CLASSES = { 'icons-bottom': ['xt-bar-split', 'xt-icons-bottom'], 'icons-top': ['xt-bar-split', 'xt-icons-top'], }; const LEGACY_BAR_LAYOUTS = { 'combined-top': 'icons-bottom', 'combined-bottom': 'icons-top', 'split-icons-bottom': 'icons-bottom', 'split-icons-top': 'icons-top', }; const ALL_BAR_CLASSES = ['xt-bar-bottom', 'xt-bar-split', 'xt-icons-bottom', 'xt-icons-top']; function resolveBarLayout(setting) { if (BAR_LAYOUT_CLASSES[setting]) { return setting; } return LEGACY_BAR_LAYOUTS[setting] || 'icons-bottom'; } function barLayoutClasses(setting) { return BAR_LAYOUT_CLASSES[resolveBarLayout(setting)]; } function iconsInMobileBar(_setting) { return true; } function applyBarLayout(setting, root, enabled = true) { const active = enabled ? barLayoutClasses(setting) : []; for (const cls of ALL_BAR_CLASSES) { root.classList.toggle(cls, active.includes(cls)); } } const QR_OVERFLOW_ACTION_IDS = [ 'qr-oekaki-button', 'qr-dogiri-button', 'qr-jpg', 'qr-view', 'qr-randomize', 'qr-restore-name', 'url-button', 'custom-cooldown-button', 'split-post', 'dump-button', ]; function actionLabel(title) { return title.replace(/\s*\([^)]*\)\s*$/, '').trim(); } function keyboardInset(innerHeight, viewportHeight, offsetTop) { return Math.max(0, Math.round(innerHeight - viewportHeight - offsetTop)); } function shouldCloseAfterPost(mobile, dumpMode, postsRemaining) { return mobile && dumpMode && postsRemaining === 0; } function shouldAutoExpand(trigger, expanded) { if (expanded) { return false; } return trigger === 'dump-on'; } const QRMobile = { enabled: false, expanded: false, nodes: null, handle: null, moreButton: null, overflow: null, setup(nodes) { this.enabled = true; this.expanded = false; this.nodes = nodes; const { el } = nodes; $.addClass(el, 'qr-mobile'); const handle = $.el('a', { href: 'javascript:;', className: 'qr-mobile-handle', title: 'Expand' }); Icon.set(handle, 'caretDown'); this.handle = handle; $.prepend(el, handle); $.on(handle, 'click', () => this.toggle()); this.buildOverflow(); const mirrored = ['has-file', 'has-image', 'has-video', 'custom-cooldown']; const syncFileState = () => { el.classList.toggle('qr-has-file', $.hasClass(nodes.fileSubmit, 'has-file')); for (const cls of mirrored) { this.overflow.classList.toggle(cls, $.hasClass(nodes.fileSubmit, cls)); } }; new MutationObserver(syncFileState).observe(nodes.fileSubmit, { attributes: true, attributeFilter: ['class'] }); syncFileState(); new MutationObserver(() => { if ($.hasClass(el, 'dump') && shouldAutoExpand('dump-on', this.expanded)) { this.toggle(true); } }).observe(el, { attributes: true, attributeFilter: ['class'] }); $.on(el, 'click', (e) => { if (!$.hasClass(el, 'qr-overflow-open')) { return; } const target = e.target; if (this.overflow.contains(target) || this.moreButton.contains(target)) { return; } this.toggleOverflow(false); }); this.trackKeyboard(el); }, trackKeyboard(el) { const vv = window.visualViewport; if (!vv) { return; } const update = () => { const inset = keyboardInset(window.innerHeight, vv.height, vv.offsetTop); if (inset) { el.style.setProperty('--qr-kb-inset', `${inset}px`); } else { el.style.removeProperty('--qr-kb-inset'); } }; vv.addEventListener('resize', update); vv.addEventListener('scroll', update); update(); }, buildOverflow() { const { el } = this.nodes; const container = $('#qr-filename-container', el); const more = $.el('a', { href: 'javascript:;', className: 'qr-action-button qr-mobile-more', title: 'More actions', textContent: '⋯' }); this.moreButton = more; $.add(container, more); const sheet = $.el('div', { className: 'qr-mobile-overflow' }); this.overflow = sheet; for (const id of QR_OVERFLOW_ACTION_IDS) { const action = $(`#${id}`, el); if (!action) { continue; } const row = $.el('div', { className: 'qr-overflow-row' }); const label = $.el('span', { className: 'qr-overflow-label', textContent: actionLabel(action.title) }); $.add(row, action); $.add(row, label); $.add(sheet, row); $.on(label, 'click', () => action.click()); } $.add(this.nodes.fileSubmit, sheet); $.on(more, 'click', () => this.toggleOverflow()); $.on(sheet, 'click', (e) => { if (e.target.closest('a, .qr-overflow-label')) { this.toggleOverflow(false); } }); }, toggle(force) { const next = force ?? !this.expanded; this.expanded = next; const el = this.nodes.el; el.classList.toggle('qr-mobile-expanded', next); if (this.handle) { this.handle.title = next ? 'Collapse' : 'Expand'; } if (!next) { this.toggleOverflow(false); } }, toggleOverflow(force) { const el = this.nodes.el; const open = force ?? !$.hasClass(el, 'qr-overflow-open'); el.classList.toggle('qr-overflow-open', open); }, }; var ferongr_unreadDead = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAFVBMVEX///9zBQC/AADpDAP/gID/q6voCwJJTwpOAAAAAXRSTlMAQObYZgAAAGJJREFUeF5Fi7ENg0AQBCfa/AFdDh2gdwPIogMK2E2+/xLslwOvdqRJhv+GQQPUCtJM7svankLrq/I+TY5e6Ueh1jyBMX7AFJi9vwfyVO4CbbO6jNYpp9GyVPbdkFhVgAQ2H0NOE5jk9DT8AAAAAElFTkSuQmCC'; var ferongr_unreadDeadY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAxUlEQVR42q1TOwrCQBB9s0FRtJI0WoqFtSLYegoP4gVSeJsUHsHSI3iFeIqRXXgwrhlXwYHHhLwPTB7B36abBCV+0pA4DUBQUNZYQptGtW3jtoKyxgoe0yrBCoyZfL/5ioQ3URZOXW9I341l3oo+NXEZiW4CEuIzvPECopED4OaZ3RNmeAm4u+a8Jr5f17VyVoL8fr8qcltzwlyyj2iqcgPOQ9ExkHAITgD75bYBe0A5S4H/P9htuWMF3QXoQpwaKeT+lnsC6JE5I6aq6fEAAAAASUVORK5CYII='; var ferongr_unreadSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAFVBMVEX///8AcH4AtswA2PJ55fKi6fIA1/FtpPADAAAAAXRSTlMAQObYZgAAAGJJREFUeF5Fi7ENg0AQBCfa/AFdDh2gdwPIogMK2E2+/xLslwOvdqRJhv+GQQPUCtJM7svankLrq/I+TY5e6Ueh1jyBMX7AFJi9vwfyVO4CbbO6jNYpp9GyVPbdkFhVgAQ2H0NOE5jk9DT8AAAAAElFTkSuQmCC'; var ferongr_unreadSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAxElEQVQ4y2NgoBq4/vE/HJOsBiRQUIfA2AzBqQYqUfn00/9FLz+BaQxDCKqBmX7jExijKEDSDJPHrnnbGQhGV4RmOFwdVkNwhQMheYwQxhaIi7b9Z9A3gWAQm2BUoQOgRhgA8o7j1ozLC4LCyAZcx6kZI5qg4kLKqggDFFWxJySsUQVzlb4pwgAJaTRvokcVNgOqOv8zcHBCsL07DgNg8YsczzA5MxtUL+DMD8g0slxI/H8GQ/P/DJKyeKIRpglXZsIiBwBhP5O+VbI/JgAAAABJRU5ErkJggg=='; var ferongr_unreadNSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAFVBMVEX///8oeQBJ3ABV/wHM/7Lu/+ZU/gAqUP3dAAAAAXRSTlMAQObYZgAAAGJJREFUeF5Fi7ENg0AQBCfa/AFdDh2gdwPIogMK2E2+/xLslwOvdqRJhv+GQQPUCtJM7svankLrq/I+TY5e6Ueh1jyBMX7AFJi9vwfyVO4CbbO6jNYpp9GyVPbdkFhVgAQ2H0NOE5jk9DT8AAAAAElFTkSuQmCC'; var ferongr_unreadNSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAx0lEQVQ4y2NgoBYI+cfwH4ZJVgMS0KhEYGyG4FQDkzjzf9P/d/+fgWl0QwiqgSkI/c8IxsgKkDXD5LFq9rwDweiK0A2HqcNqCK5wICSPEcLYAtH+AMN/IXMIBrEJRie6OEgjDAC5x3FqxuUFNiEUA67j1IweTTBxBQ1puAG86jgSEraogskJWSBcwCGF5k30qMJmgMFEhv/MXBAs5oLDAFj8IsczTE7UEeECbhU8+QGZRpaTi2b4L2zF8J9TGk80wjThykzY5AAW/2O1C2mIbgAAAABJRU5ErkJggg=='; var xat_unreadDead = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAG1BMVEX+AACLkZFub2yfaF3zZGIAAAD/AAD/iYr/zs8IPcF6AAAABXRSTlMAeprJ7xzg6IEAAABZSURBVAjXY2DABKGBSkqioQwMrGmpxsZhaQEMDGFpIa5pqSCRtPDSNJBIaGh5eShQDYOye0V7iREKAyQFYoiCFAcyILQDGcGmEEZYkGoqiMHKysAQEICwGwAAjBmBqhYlagAAAABJRU5ErkJggg=='; var xat_unreadDeadY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAPFBMVEUAAACEgoBva2ilamDxcG7IaWYgFBNOSEf//f0PDQwBAAA7LCwAAAD/AAD+hIX+m5z+zc5HAADPAAAGAADl032uAAAADHRSTlMAzNv0/vz+6v3+7ALrmfyXAAAAaUlEQVQY042PyxKAIAhFAc1eV7T6/3/N8VXOtAgWwBm4ANEPA8AswpySXHvvYZLlpBNrh9pDtcSqAQ1BUTVIjNUQY5icmwfglmXNgE0d6QBF9GigrU0A9LoM53U1kFzk6SBQuWfD/vHqDUCpBmVKTTM4AAAAAElFTkSuQmCC'; var xat_unreadSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAIVBMVEUAAACRjop4dXVpZ2tdcI9dfKdisfMAAAAumMN9xv+s2/+PADT2AAAAB3RSTlMAepGdv83v3HIc4QAAAFxJREFUCNdjYMAE5YXKRuLlDAzsHe2uIRUdBQwMFR1l6R3tIJGOyukdIJHy8lkry4FqGEwzV62aFozMUAFJOQEZ4iDFhQwI7UBGaTiEUVFs3g5isLMzMBQUIOwGAJRlIu9hk08QAAAAAElFTkSuQmCC'; var xat_unreadSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAMFBMVEUAAACAgYVlc4ljsu4AAAAAAAAAAAAumMODyP6b1P6e1f/g8v89msgSIiwNFxwbPU3tQYj5AAAABnRSTlMAxej+9VTmD9ciAAAAZElEQVQI12NgwARpiUKKYmkMDGzlZUpK6eUJDAzp5clm5WUgkfKMtnKQSFpa54o0oBoGJYvZO88+gjJu7wMyhIBS2SCGGFDxaxADpP32NjAjSe0bSFd6epIaWISNjYEhJRVhNwAGlyJpYtcvcAAAAABJRU5ErkJggg=='; var xat_unreadNSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAHlBMVEUfJSCRi5Frbm9dn19082KR/30AAABmzDOq/5vZ/9Gt/vt2AAAABnRSTlMAe5rJ7/4vxEp4AAAAWUlEQVQI12NgwARpiUpKYmkMDGzlZcbG6eUJDAzp5Slu5WUgkfLUsHKQSFpaRGsaUA2DsmvnjBAjFAZICsQQAylOZEBoBzKSzSCM9CS1MhCDjY2BISEBYTcAtgAcKSK2vuIAAAAASUVORK5CYII='; var xat_unreadNSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAM1BMVEUAAACBj39tfm1qj2RepFlu2VQAAQAAAAAAAABmyzOX/oSr/pus/pzk/98PGgtatC4CBAI1ENblAAAACHRSTlMA09/p9v77ig0SBcQAAABnSURBVBjTjY9LDsAgCEQRsR2xWu9/2hK/adJFYQG8wABEPwyAYzNnSatjjPAiviWLhPCqI1R7HBrQdCmGBrEETTmnUAq/QMm5dODHyAQOXXR1zLUGsIEI7lonMGfeHQTq9xw4P159AIxSBSC53km7AAAAAElFTkSuQmCC'; var Mayhem_unreadDead = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABFklEQVR4AZ2R4WqEMBCEFy1yiJQQ14gcIhIuFBFR+qPQ93+v66QMksrlTwMfkZ2ZZbMKTgVqYIDl3YAbeCM31lJP/Zul4MAEPJjBQGNDLGsz8PQ6aqLAP5PTdd1WlmU09mSKtdTDRgrkzspJPKq6RxMahfj9yhOzQEZwZAwfzrk1ox3MXibIN8hO4MAjeV72CemJGWblnRsOYOdoGw0jebB20BPAwKzUQPlrFhrXFw1Wagu9yuzZwINzVAZCURRL+gRr7Wd8Vtqg4Th/lsUmewyk9WQ/A7NiwJz5VV/GmO+MNjMrFvh/NPDMigHTaeJN09a27ZHRJmalBg54CgfvAGYSLpoHjlmpuAwFdzDy7oGS/qIpM9UPFGg1b1kUlssAAAAASUVORK5CYII='; var Mayhem_unreadDeadY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABR0lEQVR4AYWSQWq0QBCFCw0SRIK0PQ4hiIhEZBhEySLyewUPEMgqR/JIXiDhzz7kKKYePIZajEzDRxfV9dWU3SO6IiVWUsVxT5R75Y4gTmwNnUh4kCulUiuV8sjChDjmKtaUcHgmHsnNrMPh0IVhiMIjKZGzNXDoyhMzF7C89z2KtFGD+FoNXEUKZdgpaPM8P++cDXTtBDca7EyQK8+bXTufYBccuvLAG26UnqN1LCgI4g/lm7zTgSux4vk0J8rnKw3+m1//pBPbBrVyGZVNmiAITviEtm3t+D+2QcJx7GUxlN4594K4ZY75Xzh0JVWqnad6TdP0H+LRNBjHcYNDV5xS32qwaC4my7Lwn6guu5QoomgbdFmWDYhnM8E8zxscuhLzPWtKA/dGqUizrityX9M0YX+DQ1ciXobnP6vgfmTOM7Znnk70B58pPaEvx+epAAAAAElFTkSuQmCC'; var Mayhem_unreadSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA/ElEQVR4AZ3RUWqEMBSF4ftQZAhSREQJIiIXpQwi+tSldkFdWPsLhyEE0ocKH2Fyzg1mNJ4KAQ1arTUeeJMH6qwTUJmCHjMcC6KKtbSIylzdXpl18J/k4fdTpUFmPLOOa9bGe+P4+n5RYYfLXuiMsAlXofBxK2QXpvwN/jqg+AY91vR+pStk+apZe0fEhhMXDhUmWXEoO9WNmrWAzvRPq7jnB2jvUGfWTEgPcJzZFTbZk/0Tnh5QI+af6lVGvq/Do2atwVL4VJ+3QrZo1lr4Pw5wzVqDWaV7SUvHrZDNmrWAHq7g0rphkS3LXDMBVqFGhxGT1gGdDFnWaab6BRmXRvbxDmYiAAAAAElFTkSuQmCC'; var Mayhem_unreadSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABQElEQVR4AY2SQUrEQBBFS9CMNFEkhAQdYmiCIUgcZlYGc4VsBcGVF/AuWXme4F7RtXiVWF9+Y9MYtOHRTdX/NZWaEj2RYpQTJeEdK4fKPuA7DjSGXiQkU0qlUqxySmFMEsYsNSU8zEmK4OwdEbmkKCclYoGmolfWCGyenh1O0EJE2gXNWpFC2S0IGrCQ29EbdPCPAmEHmXIxByf8hDAPD71yzAnXypatbSgoAN8Pyju5h4deMUrqJk1z+0uBN+/XX+gxfoFK2QafUJO2aRq//Q+/QIx2wr+Kwq0rusrP/QKf9MTCtbQLf9U1wNvYnz3qug45S68kSvVXgbPbx3nvYPXNOI7cRPWySukK+DcGCvA+urqZ3RmGAbmSXjFK5rpwW8nhWVJP04TYa9/3uO/goVciDiPlZhW8c8ZAHuRSeqIv32FK/GYGL8YAAAAASUVORK5CYII='; var Mayhem_unreadNSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA/ElEQVR4AZ3RUWqEMBSF4ftQZAihDCKKiAQJShERQx+6o662e2p/4TCEQF468BEm95yLovFr4PBEq9PjgTd5wBcZp6559AiIWDAq6KXV3aJMUMfDOsTf7Mf/XaFBAvYiE9W16b74/vl8UeBAlKOSmWAzUiXwcavMkrrFE9QXVJ+gx5q9XvUVivmqrr1jxIYLCacCs6y6S8psGNU1hw4Bu4JHuUB3pzJBHZcviLiKV9jkyO4vxHyBx1h+qlcY5b2Wj+raE0vlU33dKrNFXWsR/7EgqmtPBIXuIw+dt8osqGsOPaIGSeeGRbZiFtVxsAYeHSbMOgd0MhSzTp3mD4RaQX4aW3NMAAAAAElFTkSuQmCC'; var Mayhem_unreadNSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABP0lEQVR4AYWS0UqFQBCGhziImNRBRImDmUgiIaF0kWSP4AMEXXXTE/QiPpL3UdR19Crb/PAvLEtyFj5mmfn/cdxd0RUokbJXEsZYCZUd4D72NBG8wkKmlEqtVMoFhTFJmKuoKelBTVIkjbNE5IainJTIeZqaXjkg8fp+Z7GCjiLQbWgOihTKsCFowUZtoNef4HgDf4JMuTbe8n/Br8NDr5zxhBul52i3FBQE+xflmzzTA69ESmpPmubunwZfztc/6IncBrXSe7/QkK5tW3f8H7dBjHH8q6Kwt033V6Hb4JeeWPgsq42rugfYZ92psWscRwMPvZIo9bEGD2+F2YUnBizLwpeoXnYpbQM34kAB9peP58aueZ4NPPRKxPusaRoYG6UizbquyH1O04T4RA+8EvAwUr6sgjFnDuReLaUn+ANygUa7+9SCWgAAAABJRU5ErkJggg=='; var fourChanJS_unreadDead = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAD1BMVEUBAAAAAAD/AABnZ2f///8nFk05AAAAAXRSTlMAQObYZgAAAEFJREFUeNqNjgEKACAMAjvX/98cAkkxgmSgO8Bt/Ai4ApJ6KKhzF3OiEMDASrGB/QWgPEHsUpN+Ng9xAETMYhDrWmeHAMcmvycWAAAAAElFTkSuQmCC'; var fourChanJS_unreadDeadY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAD1BMVEUAAAAAAAD/AABmZmYA/wBD99DBAAAAAXRSTlMAQObYZgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAE9JREFUCNdljcsRACEIQ5MOiNKAdGAJ9N/Uiu7nsMzABHgB4B8ygFoZA2hhVWavhhGeURPJU9q45+17hGbfGxa82Ndex3hEM44SJGD2/b4AzDgGlHbl388AAAAASUVORK5CYII='; var fourChanJS_unreadSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAD1BMVEUBAAAAAAAul8NnZ2f////82iC9AAAAAXRSTlMAQObYZgAAAEFJREFUeNqNjgEKACAMAjvX/98cAkkxgmSgO8Bt/Ai4ApJ6KKhzF3OiEMDASrGB/QWgPEHsUpN+Ng9xAETMYhDrWmeHAMcmvycWAAAAAElFTkSuQmCC'; var fourChanJS_unreadSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAD1BMVEUAAAAAAAAul8NnZ2f/AAD7B+mqAAAAAXRSTlMAQObYZgAAAAlwSFlzAAALEgAACxIB0t1+/AAAAE9JREFUCNdljcsRACEIQ5MOiNKAdGAJ9N/Uiu7nsMzABHgB4B8ygFoZA2hhVWavhhGeURPJU9q45+17hGbfGxa82Ndex3hEM44SJGD2/b4AzDgGlHbl388AAAAASUVORK5CYII='; var fourChanJS_unreadNSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAElBMVEUBAAAAAABmzDNlyjJnZ2f///+6o7dfAAAAAXRSTlMAQObYZgAAAERJREFUeF6NjkEKADEIA51o///lJZfQxUsHITogWi8AvwZJuxmYa25xDooBLEwOWFTYAsYVhdorLZt9Ng9xCUTCUCQ2H3F4ANrZ2WNiAAAAAElFTkSuQmCC'; var fourChanJS_unreadNSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAD1BMVEUAAAAAAABmzDNmZmb/AAC8/wCMAAAAAXRSTlMAQObYZgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAE9JREFUCNdljcsRACEIQ5MOiNKAdGAJ9N/Uiu7nsMzABHgB4B8ygFoZA2hhVWavhhGeURPJU9q45+17hGbfGxa82Ndex3hEM44SJGD2/b4AzDgGlHbl388AAAAASUVORK5CYII='; var Original_unreadDead = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAgMAAABinRfyAAAADFBMVEX/////AAD///8AAABBZmS3AAAAAXRSTlMAQObYZgAAAExJREFUeF4tyrENgDAMAMFXKuQswQLBG3mOlBnFS1gwDfIYLpEivvjq2MlqjmYvYg5jWEzCwtDSQlwcXKCVLrpFbvLvvSf9uZJ2HusDtJAY7Tkn1oYAAAAASUVORK5CYII='; var Original_unreadDeadY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAhElEQVR42q1RwQnAMAjMu5M4guAKXa4j5dUROo5tipSDcrFChUONd0di2m/hEGVOHDyIPufgwAFASDkpoSzmBrkJ2UMyR9LsJ3rvrqo3Rt1YMIMhhNnOxLMnoMFBxHyJAr2IOBFzA8U+6pLBdmEJTA0aMVjpDd6Loks0s5HZNwYx8tfZCZ0kll7ORffZAAAAAElFTkSuQmCC'; var Original_unreadSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAgMAAABinRfyAAAADFBMVEX///8ul8P///8AAACaqgkzAAAAAXRSTlMAQObYZgAAAExJREFUeF4tyrENgDAMAMFXKuQswQLBG3mOlBnFS1gwDfIYLpEivvjq2MlqjmYvYg5jWEzCwtDSQlwcXKCVLrpFbvLvvSf9uZJ2HusDtJAY7Tkn1oYAAAAASUVORK5CYII='; var Original_unreadSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAALVBMVEUAAAAAAAAAAAAAAAABBQcHFx4KISoNLToaVW4oKCgul8M4ODg7OzvBwcH///8uS/CdAAAAA3RSTlMAx9dmesIgAAAAV0lEQVR42m2NWw6AIBAD1eILZO5/XI0UAgm7H9tOsu0yGWAQSOoFijHOxOANGqm/LczpOaXs4gISrPZ+gc2+hO5w2xdwgOjBFUIF+sEJrhUl9JFr+badFwR+BfqlmGUJAAAAAElFTkSuQmCC'; var Original_unreadNSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAgMAAABinRfyAAAADFBMVEX///9mzDP///8AAACT0n1lAAAAAXRSTlMAQObYZgAAAExJREFUeF4tyrENgDAMAMFXKuQswQLBG3mOlBnFS1gwDfIYLpEivvjq2MlqjmYvYg5jWEzCwtDSQlwcXKCVLrpFbvLvvSf9uZJ2HusDtJAY7Tkn1oYAAAAASUVORK5CYII='; var Original_unreadNSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAALVBMVEUAAAAAAAAAAAAAAAAECAIQIAgWLAsePA8oKCg4ODg6dB07OztmzDPBwcH///+rsf3XAAAAA3RSTlMAx9dmesIgAAAAV0lEQVR42m2NWw6AIBAD1eIDhbn/cTVSCCTsfmw7ybbLZIBBIKkXKKU0E4M3aKT+tjCn5xiziwuIsNr7BTb7ErrDZV/AAaIHdwgV6AcnuFaU0Eeu5dt2XiUyBjCQ2bIrAAAAAElFTkSuQmCC'; var Metro_unreadDead = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAC/AABrZQDiAAAAAXRSTlMAQObYZgAAABJJREFUCB1jZGBgrMNAQEEc4gCSfAX5bRw/NQAAAABJRU5ErkJggg=='; var Metro_unreadDeadY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAJFBMVEUAAAAAAAAAAAC/AAD///8dAAApAABsAAAHAAA4AACQAAAsAABMCpCvAAAAA3RSTlMAPse+s4iwAAAAMklEQVQI12NggAFmY2MDECaNAQZCilAzVJyg5oS4GqAxUtygjIp2KGOKJ5SxepcB3BUAcdYRqxAtgFoAAAAASUVORK5CYII='; var Metro_unreadSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAAA1/GhpCidAAAAAXRSTlMAQObYZgAAABJJREFUCB1jZGBgrMNAQEEc4gCSfAX5bRw/NQAAAABJRU5ErkJggg=='; var Metro_unreadSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAJFBMVEUAAAAAAAAAAAAA1/H///8AISUALzQAeokACAkAQEcAorYAMTcE9WFNAAAAA3RSTlMAPse+s4iwAAAAMklEQVQI12NggAFmY2MDECaNAQZCilAzVJyg5oS4GqAxUtygjIp2KGOKJ5SxepcB3BUAcdYRqxAtgFoAAAAASUVORK5CYII='; var Metro_unreadNSFW = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAABV/wErM5hwAAAAAXRSTlMAQObYZgAAABJJREFUCB1jZGBgrMNAQEEc4gCSfAX5bRw/NQAAAABJRU5ErkJggg=='; var Metro_unreadNSFWY = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAJFBMVEUAAAAAAAAAAABV/wH///8NKAASOAAwkQADCgAZTABAwQATOwC5e3VGAAAAA3RSTlMAPse+s4iwAAAAMklEQVQI12NggAFmY2MDECaNAQZCilAzVJyg5oS4GqAxUtygjIp2KGOKJ5SxepcB3BUAcdYRqxAtgFoAAAAASUVORK5CYII='; var dead = 'R0lGODlhEAAQAKECAAAAAP8AAP///////yH5BAEKAAIALAAAAAAQABAAAAIvlI+pq+D9DAgUoFkPDlbs7lFZKIJOJJ3MyraoB14jFpOcVMpzrnF3OKlZYsMWowAAOw=='; var empty = 'R0lGODlhEAAQAJEAAAAAAP///9vb2////yH5BAEAAAMALAAAAAAQABAAAAIvnI+pq+D9DBAUoFkPFnbs7lFZKIJOJJ3MyraoB14jFpOcVMpzrnF3OKlZYsMWowAAOw=='; var Favicon = { el: null, status: null, isSFW: false, default: '', unread: '', unreadY: '', unreadDead: '', unreadDeadY: '', unreadSFW: '', unreadSFWY: '', unreadNSFW: '', unreadNSFWY: '', init() { return $.asap((() => d.head && (Favicon.el = $('link[rel="shortcut icon"]', d.head))), Favicon.initAsap); }, set(status) { Favicon.status = status; if (Favicon.el) { Favicon.el.href = Favicon[status]; return $.add(d.head, Favicon.el); } }, initAsap() { Favicon.el.type = 'image/x-icon'; const { href } = Favicon.el; Favicon.isSFW = /ws\.ico$/.test(href); Favicon.default = href; Favicon.switch(); if (Favicon.status) { return Favicon.set(Favicon.status); } }, switch() { let items = { ferongr: [ ferongr_unreadDead, ferongr_unreadDeadY, ferongr_unreadSFW, ferongr_unreadSFWY, ferongr_unreadNSFW, ferongr_unreadNSFWY, ], 'xat-': [ xat_unreadDead, xat_unreadDeadY, xat_unreadSFW, xat_unreadSFWY, xat_unreadNSFW, xat_unreadNSFWY, ], Mayhem: [ Mayhem_unreadDead, Mayhem_unreadDeadY, Mayhem_unreadSFW, Mayhem_unreadSFWY, Mayhem_unreadNSFW, Mayhem_unreadNSFWY, ], '4chanJS': [ fourChanJS_unreadDead, fourChanJS_unreadDeadY, fourChanJS_unreadSFW, fourChanJS_unreadSFWY, fourChanJS_unreadNSFW, fourChanJS_unreadNSFWY, ], Original: [ Original_unreadDead, Original_unreadDeadY, Original_unreadSFW, Original_unreadSFWY, Original_unreadNSFW, Original_unreadNSFWY, ], 'Metro': [ Metro_unreadDead, Metro_unreadDeadY, Metro_unreadSFW, Metro_unreadSFWY, Metro_unreadNSFW, Metro_unreadNSFWY, ] }; items = $.getOwn(items, Conf['favicon']); const f = Favicon; const t = 'data:image/png;base64,'; let i = 0; while (items[i]) { items[i] = t + items[i++]; } [f.unreadDead, f.unreadDeadY, f.unreadSFW, f.unreadSFWY, f.unreadNSFW, f.unreadNSFWY] = items; return f.update(); }, update() { if (this.isSFW) { this.unread = this.unreadSFW; return this.unreadY = this.unreadSFWY; } else { this.unread = this.unreadNSFW; return this.unreadY = this.unreadNSFWY; } }, SFW: '//s.4cdn.org/image/favicon-ws.ico', NSFW: '//s.4cdn.org/image/favicon.ico', dead: `data:image/gif;base64,${dead}`, logo: `data:image/png;base64,${empty}`, }; const CaptchaReplace = { init() { if ((g.SITE.software !== 'yotsuba') || (d.cookie.indexOf('pass_enabled=1') >= 0)) { return; } if (Conf['Force Noscript Captcha'] && Main.jsEnabled) { $.ready(this.noscript); return; } if (Conf['captchaLanguage'].trim()) { if (['boards.4chan.org', 'boards.4channel.org'].includes(location.hostname)) { $.onExists(doc, '#captchaFormPart', node => $.onExists(node, 'iframe[src^="https://www.google.com/recaptcha/"]', this.iframe)); } else { $.onExists(doc, 'iframe[src^="https://www.google.com/recaptcha/"]', this.iframe); } } }, noscript() { let noscript, original, toggle; if (!((original = $('#g-recaptcha')) && (noscript = $('noscript', original.parentNode)))) { return; } const span = $.el('span', { id: 'captcha-forced-noscript' }); $.replace(noscript, span); $.rm(original); const insert = function () { span.innerHTML = noscript.textContent; this.iframe($('iframe[src^="https://www.google.com/recaptcha/"]', span)); }; if (toggle = $('#togglePostFormLink a, #form-link')) { $.on(toggle, 'click', insert); } else { insert(); } }, iframe(iframe) { let lang; if (lang = Conf['captchaLanguage'].trim()) { const src = /[?&]hl=/.test(iframe.src) ? iframe.src.replace(/([?&]hl=)[^&]*/, '$1' + encodeURIComponent(lang)) : iframe.src + `&hl=${encodeURIComponent(lang)}`; if (iframe.src !== src) { iframe.src = src; } } } }; const ROW_TOLERANCE = 2; function stepHorizontal(current, delta, length) { if (length <= 0) { return -1; } if (current < 0 || current >= length) { return 0; } return (current + delta + length) % length; } function stepVertical(positions, current, delta) { if (!positions.length) { return -1; } if (current < 0 || current >= positions.length) { return 0; } const rows = []; let prevTop = -Infinity; positions.forEach((pos, index) => { if (Math.abs(pos.top - prevTop) > ROW_TOLERANCE) { rows.push([]); prevTop = pos.top; } rows[rows.length - 1].push(index); }); const rowIndex = rows.findIndex(row => row.includes(current)); const targetRow = rows[rowIndex + delta]; if (!targetRow) { return current; } const currentLeft = positions[current].left; let best = targetRow[0]; for (const index of targetRow) { if (Math.abs(positions[index].left - currentLeft) < Math.abs(positions[best].left - currentLeft)) { best = index; } } return best; } const CAPTCHA_STYLE_VALUES = ['classic', 'inline', 'dots']; function normalizeCaptchaStyle(value) { return CAPTCHA_STYLE_VALUES.includes(value) ? value : 'inline'; } function applyCaptchaStyleClass(root, style) { for (const name of CAPTCHA_STYLE_VALUES) { root.classList.toggle(`fourchanx-captcha-style-${name}`, style === name); } } const NARRATION = { complete: 'Done.', review: 'Click an image to edit, or post.', }; function captchaNarration(style, kind) { return style === 'classic' ? NARRATION[kind] : ''; } const CircleSvg = 'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512z'; const CircleW = 512, CircleH = 512; const CircleCheckSvg = 'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z'; const CircleCheckW = 512, CircleCheckH = 512; const CircleExclamationSvg = 'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z'; const CircleExclamationW = 512, CircleExclamationH = 512; const CircleNotchSvg = 'M222.7 32.1c5 16.9-4.6 34.8-21.5 39.8C121.8 95.6 64 169.1 64 256c0 106 86 192 192 192s192-86 192-192c0-86.9-57.8-160.4-137.1-184.1c-16.9-5-26.6-22.9-21.5-39.8s22.9-26.6 39.8-21.5C434.9 42.1 512 140 512 256c0 141.4-114.6 256-256 256S0 397.4 0 256C0 140 77.1 42.1 182.9 10.6c16.9-5 34.8 4.6 39.8 21.5z'; const CircleNotchW = 512, CircleNotchH = 512; const CircleXmarkSvg = 'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z'; const CircleXmarkW = 512, CircleXmarkH = 512; const getTCaptcha = () => window.TCaptcha || window.wrappedJSObject?.TCaptcha || (typeof unsafeWindow !== 'undefined' ? unsafeWindow.TCaptcha : undefined); const captchaStatusIcon = (svgPathData, width, height) => (``); const captchaStatusIcons = { idle: captchaStatusIcon(CircleSvg, CircleW, CircleH), loading: captchaStatusIcon(CircleNotchSvg, CircleNotchW, CircleNotchH), complete: captchaStatusIcon(CircleCheckSvg, CircleCheckW, CircleCheckH), failed: captchaStatusIcon(CircleXmarkSvg, CircleXmarkW, CircleXmarkH), expired: captchaStatusIcon(CircleExclamationSvg, CircleExclamationW, CircleExclamationH), }; const CaptchaT = { init() { if (d.cookie.indexOf('pass_enabled=1') >= 0) { return; } if (!(this.isEnabled = !!$('#t-root') || !$.id('postForm'))) { return; } const root = $.el('div', { className: 'captcha-root' }); root.tabIndex = -1; this.nodes = { root }; $.addClass(QR.nodes.el, 'has-captcha', 'captcha-t'); $.after(QR.nodes.com.parentNode, root); }, moreNeeded() { }, getThread() { return { boardID: g.BOARD.ID, threadID: QR.posts[0].thread === 'new' ? '0' : ('' + QR.posts[0].thread), }; }, setup(focus, force) { if (!this.isEnabled) { return; } const TCaptcha = getTCaptcha(); if (!TCaptcha?.init) { QR.error('Captcha unavailable. Reload the page and try again.'); return; } this.patchFormatter(TCaptcha); this.setStacked(!!Conf['Stacked TCaptcha'], TCaptcha); if (!this.nodes.container) { this.nodes.container = $.el('div', { className: 'captcha-container' }); $.prepend(this.nodes.root, this.nodes.container); this.currentThread = this.getThread(); TCaptcha.init(this.nodes.container, this.currentThread.boardID, +this.currentThread.threadID); this.setStacked(!!Conf['Stacked TCaptcha'], TCaptcha); TCaptcha.setErrorCb?.(() => { }); if (Conf['Auto-load captcha']) { TCaptcha.load(this.currentThread.boardID, this.currentThread.threadID); this.setState('loading'); } } else if (Conf['Auto-load captcha'] && Conf['Stacked TCaptcha']) { $('#t-load', this.nodes.container)?.click(); this.setState('loading'); } this.ensureLoadButtonHook(); this.ensureQrFocusHook(); this.ensureStatusNode(); this.ensureProgressNode(); this.ensureCrumbsNode(); this.ensureStyleChangeHook(); if (!this.cachedButtons?.length) { this.setState('idle'); this.updateProgress(); } this.startThemeObservers(); this.applyAdaptiveTextColors(); if (focus) { $('#t-resp')?.focus(); } }, destroy() { if (!this.isEnabled || !this.nodes.container) { return; } getTCaptcha()?.destroy?.(); $.rm(this.nodes.container); delete this.nodes.container; this.cachedButtons = []; this.currentHighlightIndex = -1; this.crumbHighlightIndex = -1; this.answerHistory = []; this.editingIndex = null; this.pendingSubmitIntent = false; this.stopThemeObservers(); this.setState('idle'); }, updateThread() { if (!this.isEnabled) { return; } const { boardID, threadID } = (this.currentThread || {}); const next = this.getThread(); if ((next.boardID !== boardID) || (next.threadID !== threadID)) { this.destroy(); this.setup(); } }, parseCssColor(value) { const match = /rgba?\(([^)]+)\)/i.exec(value || ''); if (!match) { return null; } const parts = match[1].split(',').map(part => part.trim()); if (parts.length < 3) { return null; } const r = Number(parts[0]); const g = Number(parts[1]); const b = Number(parts[2]); const a = parts[3] == null ? 1 : Number(parts[3]); if ([r, g, b, a].some(Number.isNaN)) { return null; } return { r, g, b, a }; }, getBackgroundColor(node) { for (let el = node; el; el = el.parentElement) { const color = this.parseCssColor(getComputedStyle(el).backgroundColor); if (color && color.a > 0) { return color; } } return { r: 255, g: 255, b: 255, a: 1 }; }, luminance({ r, g, b }) { const toLinear = (channel) => { const c = channel / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); }, contrast(a, b) { const la = this.luminance(a); const lb = this.luminance(b); const [max, min] = la > lb ? [la, lb] : [lb, la]; return (max + 0.05) / (min + 0.05); }, pickReadableTextColor(node) { const bg = this.getBackgroundColor(node); const light = { r: 245, g: 245, b: 245 }; const dark = { r: 17, g: 17, b: 17 }; return this.contrast(light, bg) >= this.contrast(dark, bg) ? 'rgb(245, 245, 245)' : 'rgb(17, 17, 17)'; }, applyAdaptiveTextColors() { const container = this.nodes?.container; if (!container) { return; } for (const el of container.querySelectorAll('#t-load, #t-next')) { const color = this.pickReadableTextColor(el); el.style.setProperty('color', color, 'important'); el.style.setProperty('-webkit-text-fill-color', color, 'important'); } for (const el of container.querySelectorAll('#t-msg, #t-desc, #t-task, .fourchanx-captcha-load-hint, .fourchanx-captcha-status-text, .fourchanx-captcha-progress, .fourchanx-captcha-crumb')) { const color = this.pickReadableTextColor(el); el.style.setProperty('color', color, 'important'); el.style.setProperty('-webkit-text-fill-color', color, 'important'); } }, scheduleAdaptiveTextColors() { if (this.adaptiveColorRaf) { return; } this.adaptiveColorRaf = requestAnimationFrame(() => { this.adaptiveColorRaf = 0; this.applyAdaptiveTextColors(); }); this.themeSettleTimers || (this.themeSettleTimers = []); for (const delay of [0, 40, 120]) { this.themeSettleTimers.push(setTimeout(() => this.applyAdaptiveTextColors(), delay)); } }, startThemeObservers() { if (this.themeObserver) { return; } const onChange = () => this.scheduleAdaptiveTextColors(); this.themeObserver = new MutationObserver(onChange); this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] }); if (document.body) { this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class', 'style'] }); } this.headObserver = new MutationObserver(onChange); this.headObserver.observe(document.head, { childList: true, subtree: true, characterData: true }); this.prefersDarkQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); if (this.prefersDarkQuery?.addEventListener) { this.prefersDarkListener = onChange; this.prefersDarkQuery.addEventListener('change', this.prefersDarkListener); } }, stopThemeObservers() { this.themeObserver?.disconnect(); this.themeObserver = null; this.headObserver?.disconnect(); this.headObserver = null; if (this.prefersDarkQuery?.removeEventListener && this.prefersDarkListener) { this.prefersDarkQuery.removeEventListener('change', this.prefersDarkListener); } this.prefersDarkListener = null; this.prefersDarkQuery = null; if (this.adaptiveColorRaf) { cancelAnimationFrame(this.adaptiveColorRaf); this.adaptiveColorRaf = 0; } if (this.themeSettleTimers?.length) { for (const timer of this.themeSettleTimers) { clearTimeout(timer); } this.themeSettleTimers = []; } }, getOne() { if (!this.nodes.container) { return null; } const response = $(`[name='t-response']`, this.nodes.container)?.value?.trim(); const challenge = $(`[name='t-challenge']`, this.nodes.container)?.value?.trim(); if (response) { if (!challenge) { return null; } return { 't-response': response, 't-challenge': challenge, }; } let isVerificationNotRequired = false; for (const el of this.nodes.container.querySelectorAll('#t-msg, #t-task, #t-desc, .fourchanx-captcha-load-hint, .fourchanx-captcha-status-text')) { if (/Verification not required/i.test(el.textContent || '')) { isVerificationNotRequired = true; break; } } if (!isVerificationNotRequired || !challenge) { return null; } return { 't-response': '', 't-challenge': challenge, }; }, setUsed() { if (this.isEnabled && this.nodes.container) { getTCaptcha()?.clearChallenge?.(); this.setState('idle'); this.updateProgress(); } }, forceLoad() { if (!this.isEnabled || !this.nodes.container) { return; } const TCaptcha = getTCaptcha(); if (!TCaptcha?.load) { return; } this.currentThread = this.getThread(); TCaptcha.saveTicket?.(false); TCaptcha.clearChallenge?.(); TCaptcha.load(this.currentThread.boardID, this.currentThread.threadID); this.setState('loading'); }, occupied() { return !!this.nodes.container; }, setState(state) { const root = $('#qr'); if (!root) { return; } for (const name of ['idle', 'loading', 'ready', 'complete', 'failed', 'expired']) { root.classList.remove(`captcha-t-state-${name}`); } root.classList.add(`captcha-t-state-${state}`); const container = this.nodes?.container; if (!container) { return; } const borderColors = { idle: '#8ca0b5', loading: '#6f93b8', ready: '#8ca0b5', complete: '#2c9c47', failed: '#cf4a4a', expired: '#d0a64d', }; const showBorder = state === 'failed' || state === 'expired' || (state === 'complete' && this.currentStyle() === 'classic'); const borderColor = borderColors[state] || borderColors.idle; const loadButton = $('#t-load', container); container.style.border = '2px solid transparent'; container.style.borderRadius = '4px'; container.style.transition = 'border-color .2s ease'; if (loadButton) { loadButton.style.border = showBorder ? `2px solid ${borderColor}` : ''; loadButton.style.borderRadius = '4px'; loadButton.style.transition = 'border-color .2s ease'; loadButton.style.opacity = showBorder ? '1' : ''; } this.setStatusMessage({ idle: '', loading: '', ready: '', complete: captchaNarration(this.currentStyle(), 'complete'), failed: 'Failed.', expired: 'Expired.', }[state] || '', state); this.applyAdaptiveTextColors(); }, ensureLoadButtonHook() { const container = this.nodes?.container; if (!container) { return; } const loadButton = $('#t-load', container); if (!loadButton || loadButton.dataset.fourchanxHooked) { return; } loadButton.addEventListener('click', () => this.setState('loading')); loadButton.dataset.fourchanxHooked = '1'; }, ensureQrFocusHook() { const qr = $('#qr'); if (!qr || qr.dataset.fourchanxCaptchaFocusHook) { return; } qr.dataset.fourchanxCaptchaFocusHook = '1'; qr.addEventListener('click', (e) => { if (!(e.target instanceof Element)) { return; } if (!this.nodes?.root || !this.nodes.container) { return; } if (this.nodes.root.contains(e.target)) { if (e.target.closest('input, textarea, select, [contenteditable]')) { return; } this.nodes.root.focus({ preventScroll: true }); return; } if (e.target.closest('input, textarea, select, button, a, label, [contenteditable]')) { return; } if (!this.cachedButtons?.length && !this.reviewCrumbs().length) { return; } this.nodes.root.focus({ preventScroll: true }); }); }, ensureStyleChangeHook() { if (this.styleChangeHooked) { return; } this.styleChangeHooked = true; $.on(d, 'StackedCaptchaStyleChanged', () => { const TCaptcha = getTCaptcha(); if (!this.isEnabled || !TCaptcha || !this.nodes?.container) { return; } this.setStacked(!!Conf['Stacked TCaptcha'], TCaptcha); if (!Conf['Stacked TCaptcha']) { return; } if (TCaptcha.tasks?.length && TCaptcha.taskId >= TCaptcha.tasks.length) { this.showReview(TCaptcha); } else { this.renderCrumbs(TCaptcha); } }); }, qrHasFocus() { const qr = $('#qr'); const ae = document.activeElement; return !!(qr && ae && ae !== document.body && qr.contains(ae)); }, restoreFocus() { if (this.qrHasFocus()) { return; } this.nodes?.root?.focus?.({ preventScroll: true }); }, ensureStatusNode() { const container = this.nodes?.container; if (!container) { return; } const ctrl = $('#t-ctrl', container); const loadButton = $('#t-load', container); if (!ctrl || !loadButton || $('.fourchanx-captcha-load-hint', ctrl)) { return; } const hint = $.el('span', { className: 'fourchanx-captcha-load-hint' }); loadButton.after(hint); }, setStatusMessage(text, state = 'idle') { const container = this.nodes?.container; if (!container) { return; } const statusNode = $('.fourchanx-captcha-load-hint', container); if (!statusNode) { return; } for (const name of ['idle', 'loading', 'ready', 'complete', 'failed', 'expired']) { statusNode.classList.remove(`state-${name}`); } if (!text) { statusNode.replaceChildren(); return; } statusNode.classList.add(`state-${state}`); statusNode.replaceChildren(); const icon = captchaStatusIcons[state]; if (icon) { const iconNode = $.el('span', { className: `fourchanx-captcha-status-icon state-${state}`, title: this.plainStatusMessage(text) }); iconNode.innerHTML = icon; statusNode.appendChild(iconNode); } const message = $.el('span', { className: 'fourchanx-captcha-status-text' }); this.appendStatusMessage(message, text); statusNode.appendChild(message); this.applyAdaptiveTextColors(); }, decodeStatusMessage(text) { const decoder = document.createElement('textarea'); decoder.innerHTML = `${text || ''}`; return decoder.value; }, plainStatusMessage(text) { const html = document.createElement('div'); html.innerHTML = this.decodeStatusMessage(text); return html.textContent || ''; }, appendStatusMessage(parent, text) { const html = document.createElement('div'); html.innerHTML = this.decodeStatusMessage(text); const appendSafe = (node) => { if (node.nodeType === Node.TEXT_NODE) { parent.appendChild(document.createTextNode(node.textContent || '')); return; } if (node.nodeType !== Node.ELEMENT_NODE) { return; } const elNode = node; if (elNode.localName === 'a') { const href = elNode.getAttribute('href') || ''; if (/^https?:\/\//i.test(href)) { const link = document.createElement('a'); link.href = href; link.target = '_blank'; link.rel = 'noopener'; link.textContent = node.textContent || href; parent.appendChild(link); return; } } parent.appendChild(document.createTextNode(node.textContent || '')); }; for (const node of Array.from(html.childNodes)) { appendSafe(node); } }, messageStateFromText(text) { const plain = this.plainStatusMessage(text).toLowerCase(); if (/expired/.test(plain)) { return 'expired'; } if (/done|verification not required/.test(plain)) { return 'complete'; } if (/error|failed|couldn\'t|mistyped|malfunctioned/.test(plain)) { return 'failed'; } if (/loading/.test(plain)) { return 'loading'; } return 'idle'; }, formatTaskMessage(text, state) { const icon = captchaStatusIcons[state] || captchaStatusIcons.idle; return `
` + `` + `${text || ''}` + `
`; }, ensureProgressNode() { const container = this.nodes?.container; if (!container) { return; } const ctrl = $('#t-ctrl', container); if (!ctrl || $('.fourchanx-captcha-progress', ctrl)) { return; } const progress = $.el('span', { className: 'fourchanx-captcha-progress' }); $.add(ctrl, progress); }, updateProgress(TCaptcha) { const container = this.nodes?.container; if (!container) { return; } const progress = $('.fourchanx-captcha-progress', container); if (!progress) { return; } const tasks = TCaptcha?.tasks; if (!tasks?.length || !isFinite(TCaptcha?.taskId)) { progress.textContent = ''; return; } const current = Math.min(TCaptcha.taskId + 1, tasks.length); progress.textContent = `${current}/${tasks.length}`; }, formatDescription(str) { if (!str) { return ''; } return str .replace(/Use the scroll bar below to\s*|,\s*then click next\.?/gi, '') .replace(/(?:^|>)\s*([a-z])/i, (m) => m.toUpperCase()) + '.'; }, updateHighlight() { this.cachedButtons || (this.cachedButtons = []); this.cachedButtons.forEach((btn, index) => { const isActive = index === this.currentHighlightIndex; btn.classList.toggle('active', isActive); if (isActive) { btn.scrollIntoView({ block: 'nearest' }); } }); }, reviewCrumbs() { const container = this.nodes?.container; if (!container) { return []; } return Array.from(container.querySelectorAll('.fourchanx-captcha-crumb')); }, updateCrumbHighlight() { this.reviewCrumbs().forEach((chip, index) => { const isActive = index === this.crumbHighlightIndex; chip.classList.toggle('active', isActive); if (isActive) { chip.scrollIntoView({ block: 'nearest' }); } }); }, initializeEventHandler(container, TCaptcha) { if (!container || container.dataset.hasFourChanXStackedClick) { return; } container.addEventListener('click', (e) => { if (!(e.target instanceof Element)) { return; } const button = e.target.closest('.tcaptcha-image'); if (!button || !this.cachedButtons?.length) { return; } const index = this.cachedButtons.indexOf(button); if (index !== -1) { this.submitCaptchaAnswer(index, TCaptcha); } }); container.dataset.hasFourChanXStackedClick = '1'; }, createImageGrid(TCaptcha) { const container = $('#t-task', this.nodes.container); const task = TCaptcha.getCurrentTask?.(); if (!TCaptcha.node || !container || !task) { return; } const hadFocus = this.qrHasFocus(); this.setState('ready'); this.updateProgress(TCaptcha); TCaptcha.node.style.height = 'auto'; TCaptcha.node.style.overflow = 'visible'; let descriptionHTML = ''; if (task.img) { descriptionHTML = `
`; } else if (task.str) { descriptionHTML = `
${this.formatDescription(task.str)}
`; } else { descriptionHTML = '
'; } const imageHTMLs = (task.items || []).map((bitmap) => ``).join(''); container.innerHTML = descriptionHTML + imageHTMLs; this.cachedButtons = Array.from(container.querySelectorAll('.tcaptcha-image')); this.currentHighlightIndex = -1; this.crumbHighlightIndex = -1; this.initializeEventHandler(container, TCaptcha); TCaptcha.taskNode = container; this.ensureCrumbsNode(); this.renderCrumbs(TCaptcha); this.applyAdaptiveTextColors(); if (hadFocus) { this.restoreFocus(); } }, _autoSubmitted: false, pendingSubmitIntent: false, isEnabled: false, nodes: null, answerHistory: null, editingIndex: null, cachedButtons: null, currentHighlightIndex: -1, crumbHighlightIndex: -1, currentThread: null, adaptiveColorRaf: 0, themeSettleTimers: null, themeObserver: null, headObserver: null, prefersDarkQuery: null, prefersDarkListener: null, keyHandlerInstalled: false, formatterPatched: false, styleChangeHooked: false, maybeAutoSubmit() { if (this._autoSubmitted) { return false; } if (QR.cooldown.auto) { return false; } if (!this.pendingSubmitIntent) { if (!Conf['Post on Captcha Completion']) { return false; } if (Conf['Auto-load captcha']) { return false; } } const post = QR.posts?.[0]; const com = (QR.nodes?.com?.value || '').trim(); const quoted = (post?.quotedText || '').trim(); const hasContent = (com.length > 0 && com !== quoted) || !!post?.file; if (!hasContent) { return false; } this._autoSubmitted = true; this.pendingSubmitIntent = false; QR.submit(); return true; }, submitCaptchaAnswer(imageNumber, TCaptcha) { if (!TCaptcha?.respNode || !TCaptcha.tasks || imageNumber < 0) { return; } const lastIndex = TCaptcha.tasks.length - 1; if (lastIndex < 0) { return; } (this.answerHistory || (this.answerHistory = []))[TCaptcha.taskId] = imageNumber; TCaptcha.respNode.value = this.answerHistory.join(''); if (this.editingIndex != null) { this.closeEdit(TCaptcha); return; } const nextId = TCaptcha.taskId + 1; if (nextId <= lastIndex) { TCaptcha.setTaskId(nextId); this.createImageGrid(TCaptcha); } else if ((Conf['Post on Captcha Completion'] || this.pendingSubmitIntent) && !QR.cooldown.auto) { this._autoSubmitted = true; this.pendingSubmitIntent = false; TCaptcha.taskId = TCaptcha.tasks.length; TCaptcha.setTaskNodeContent('Done.'); this.setState('complete'); this.updateProgress(TCaptcha); this.cachedButtons = []; this.currentHighlightIndex = -1; QR.submit(); } else { this.showReview(TCaptcha); } }, answeredCount() { const h = this.answerHistory || []; let n = 0; while (n < h.length && h[n] != null) { n++; } return n; }, currentStyle() { return normalizeCaptchaStyle(Conf['Stacked TCaptcha Style']); }, taskChipImage(task, selectedIndex) { if (!task) { return null; } if (task.img) { return `data:image/png;base64,${task.img}`; } if (typeof task.str === 'string' && /]*\bsrc\s*=\s*["']([^"']+)["']/gi; let match, last = null; while ((match = re.exec(task.str))) { last = match[1]; } if (last) { return last; } } const bitmap = selectedIndex != null ? task.items?.[selectedIndex] : null; return bitmap ? `data:image/png;base64,${bitmap}` : null; }, editAnswer(index, TCaptcha) { if (!TCaptcha?.tasks || this.answerHistory?.[index] == null) { return; } if (this.editingIndex === index) { this.closeEdit(TCaptcha); return; } this.editingIndex = index; TCaptcha.setTaskId(index); this.createImageGrid(TCaptcha); this.currentHighlightIndex = this.answerHistory[index]; this.updateHighlight(); this.renderCrumbs(TCaptcha); }, closeEdit(TCaptcha) { const edited = this.editingIndex; this.editingIndex = null; const answered = this.answeredCount(); if (answered > TCaptcha.tasks.length - 1) { this.showReview(TCaptcha); if (edited != null) { this.crumbHighlightIndex = edited; this.updateCrumbHighlight(); } } else { TCaptcha.setTaskId(answered); this.createImageGrid(TCaptcha); } }, showReview(TCaptcha) { if (!TCaptcha?.tasks?.length) { return; } const hadFocus = this.qrHasFocus(); TCaptcha.taskId = TCaptcha.tasks.length; this.editingIndex = null; this.cachedButtons = []; this.currentHighlightIndex = -1; this.crumbHighlightIndex = -1; this.setState('complete'); const taskNode = $('#t-task', this.nodes.container); if (taskNode) { taskNode.replaceChildren(); } this.setStatusMessage(captchaNarration(this.currentStyle(), 'review'), 'complete'); this.updateProgress(TCaptcha); this.ensureCrumbsNode(); this.renderCrumbs(TCaptcha); this.applyAdaptiveTextColors(); if (hadFocus) { this.restoreFocus(); } }, ensureCrumbsNode() { const container = this.nodes?.container; if (!container) { return; } const ctrl = $('#t-ctrl', container); if (!ctrl || $('.fourchanx-captcha-crumbs', ctrl)) { return; } $.add(ctrl, $.el('span', { className: 'fourchanx-captcha-crumbs' })); }, renderCrumbs(TCaptcha) { const container = this.nodes?.container; if (!container) { return; } const crumbs = $('.fourchanx-captcha-crumbs', container); if (!crumbs) { return; } const hadFocus = this.qrHasFocus(); crumbs.replaceChildren(); const style = this.currentStyle(); const history = this.answerHistory || []; const answeredCount = this.answeredCount(); const tasksLen = TCaptcha?.tasks?.length || 0; if (!answeredCount || !tasksLen) { if (hadFocus) { this.restoreFocus(); } return; } const upTo = Math.min(Math.max(answeredCount - 1, TCaptcha.taskId), tasksLen - 1); for (let i = 0; i <= upTo; i++) { const answered = history[i] != null; const chip = $.el('button', { type: 'button', className: 'fourchanx-captcha-crumb', }); const src = this.taskChipImage(TCaptcha.tasks?.[i], answered ? history[i] : null); if (style === 'dots') { chip.classList.add('fourchanx-captcha-crumb--dot'); if (answered) { chip.classList.add('answered'); } if (src) { const pop = $.el('span', { className: 'fourchanx-captcha-dot-pop' }); pop.appendChild($.el('img', { src, alt: '' })); chip.appendChild(pop); } } else if (src) { chip.appendChild($.el('img', { src, alt: '' })); } else { chip.textContent = `${i + 1}`; chip.classList.add('numbered'); } if (i === this.editingIndex) { chip.classList.add('editing'); chip.title = `Click to stop editing answer ${i + 1}`; chip.addEventListener('click', () => this.editAnswer(i, TCaptcha)); } else if (this.editingIndex == null && i === TCaptcha.taskId && !answered) { chip.classList.add('current'); chip.title = `Solving step ${i + 1}`; } else if (answered) { chip.title = `Click to change answer ${i + 1}`; chip.addEventListener('click', () => this.editAnswer(i, TCaptcha)); } if (i === this.crumbHighlightIndex) { chip.classList.add('active'); } crumbs.appendChild(chip); } if (hadFocus) { this.restoreFocus(); } }, installStackedKeyHandler(TCaptcha) { if (this.keyHandlerInstalled) { return; } this.keyHandlerInstalled = true; window.addEventListener('keydown', e => { if (!TCaptcha.__fourchanXStackedEnabled) { return; } if (!this.nodes?.container || !document.body.contains(this.nodes.container)) { return; } const qr = $('#qr'); const ae = document.activeElement; const typing = ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable); if (typing || !qr || !ae || !qr.contains(ae)) { return; } const swallow = () => { e.preventDefault(); e.stopImmediatePropagation(); }; const tiles = this.cachedButtons || []; if (tiles.length) { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { swallow(); this.currentHighlightIndex = stepHorizontal(this.currentHighlightIndex, e.key === 'ArrowRight' ? 1 : -1, tiles.length); this.updateHighlight(); return; } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { swallow(); const positions = tiles.map(btn => ({ top: btn.offsetTop, left: btn.offsetLeft })); this.currentHighlightIndex = stepVertical(positions, this.currentHighlightIndex, e.key === 'ArrowDown' ? 1 : -1); this.updateHighlight(); return; } if (/^[1-9]$/.test(e.key) && !e.ctrlKey && !e.altKey && !e.metaKey) { const index = +e.key - 1; if (index < tiles.length) { swallow(); this.submitCaptchaAnswer(index, TCaptcha); } return; } if (e.key === 'Enter' && this.currentHighlightIndex >= 0) { swallow(); this.submitCaptchaAnswer(this.currentHighlightIndex, TCaptcha); return; } if (e.key === 'Escape' && this.editingIndex != null) { swallow(); this.closeEdit(TCaptcha); return; } } else { const crumbs = this.reviewCrumbs(); if (crumbs.length) { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { swallow(); this.crumbHighlightIndex = stepHorizontal(this.crumbHighlightIndex, e.key === 'ArrowRight' ? 1 : -1, crumbs.length); this.updateCrumbHighlight(); return; } if (e.key === 'Enter' && this.crumbHighlightIndex >= 0) { swallow(); this.editAnswer(this.crumbHighlightIndex, TCaptcha); return; } } } if (e.key === 'Backspace' && this.answerHistory?.length) { const ref = this.editingIndex != null ? this.editingIndex : TCaptcha.taskId; const target = ref - 1; if (target >= 0 && this.answerHistory[target] != null) { swallow(); this.editAnswer(target, TCaptcha); } } }, true); }, patchFormatter(TCaptcha) { if (this.formatterPatched) { return; } this.formatterPatched = true; const styleID = 'fourchanx-tcaptcha-formatter-style'; if (!document.getElementById(styleID)) { const style = document.createElement('style'); style.id = styleID; style.textContent = ` #qr.fourchanx-stacked-captcha .captcha-container { width: 100% !important; height: auto !important; min-height: 145px; overflow: visible !important; } #qr.fourchanx-stacked-captcha #t-ctrl { flex-wrap: wrap; gap: 4px; align-items: center; } #qr.fourchanx-stacked-captcha .fourchanx-captcha-load-hint.state-complete { flex: 0 0 auto; white-space: nowrap; overflow-wrap: normal; } #qr.fourchanx-stacked-captcha .fourchanx-captcha-load-hint.state-complete .fourchanx-captcha-status-text { white-space: nowrap; overflow-wrap: normal; } #qr.fourchanx-stacked-captcha .tcaptcha-image { padding: 0; margin: 3px; border: none; background: none; cursor: pointer !important; } #qr.fourchanx-stacked-captcha .tcaptcha-image img { height: 100%; width: 100%; display: block; } #qr.fourchanx-stacked-captcha .tcaptcha-image.active { outline: 4px solid var(--xt-variant-accent, #00c06f); outline-offset: -3px; box-shadow: 0 0 10px 2px color-mix(in srgb, var(--xt-variant-accent, #00c06f) 70%, transparent); border-radius: 2px; } #qr.fourchanx-stacked-captcha #t-desc { white-space: pre-line; text-align: center; font-size: 14px; user-select: none; width: 100%; } #qr.fourchanx-stacked-captcha #t-desc.tcaptcha-message { padding-bottom: 15px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; } #qr.fourchanx-stacked-captcha #t-desc.tcaptcha-message .tcaptcha-message-icon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 1.15em; height: 1.15em; line-height: 0; transform: translateY(-.5px); box-sizing: border-box; } #qr.fourchanx-stacked-captcha #t-desc.tcaptcha-message .tcaptcha-message-icon > .fourchanx-captcha-status-svg { display: block; width: 100%; height: 100%; } #qr.fourchanx-stacked-captcha #t-desc.tcaptcha-message.state-complete .tcaptcha-message-icon { color: #2c9c47; } #qr.fourchanx-stacked-captcha #t-desc.tcaptcha-message.state-failed .tcaptcha-message-icon { color: #cf4a4a; } #qr.fourchanx-stacked-captcha #t-desc.tcaptcha-message.state-expired .tcaptcha-message-icon { color: #c38c2f; } #qr.fourchanx-stacked-captcha #t-desc.tcaptcha-message.state-loading .tcaptcha-message-icon { color: #4f7eaa; } #qr.fourchanx-stacked-captcha #t-desc.tcaptcha-message.state-idle .tcaptcha-message-icon { color: #6f7c8f; } #qr.fourchanx-stacked-captcha #t-desc img { margin: 3px !important; max-width: 100%; height: auto; } #qr.fourchanx-stacked-captcha #t-task { display: flex; flex-wrap: wrap; gap: 3px; width: 100%; justify-content: center; margin: 0 auto; overflow: auto; max-height: 70vh; padding: 0 !important; height: auto !important; white-space: normal !important; align-items: normal !important; scrollbar-gutter: stable; overflow-x: hidden; box-sizing: border-box; } #qr.fourchanx-stacked-captcha #t-load { cursor: pointer !important; min-height: 24px; padding: 0 8px; } #qr.fourchanx-stacked-captcha #t-next { display: none !important; } #qr.fourchanx-stacked-captcha .fourchanx-captcha-progress { margin-left: auto; font-weight: bold; min-width: 3em; text-align: right; } #qr.fourchanx-stacked-captcha #t-slider { display: none !important; } #qr.fourchanx-stacked-captcha .fourchanx-captcha-crumbs { display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; align-items: center; flex: 1 0 100%; margin: 5px 0 0; } #qr.fourchanx-stacked-captcha .fourchanx-captcha-crumb { width: 40px; height: 40px; padding: 0; border: 1px solid currentColor; border-radius: 3px; background: none; cursor: pointer; font-size: 13px; line-height: 1; font-weight: bold; opacity: .7; box-sizing: border-box; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; } #qr.fourchanx-stacked-captcha .fourchanx-captcha-crumb img { width: 100%; height: 100%; object-fit: cover; display: block; } #qr.fourchanx-stacked-captcha .fourchanx-captcha-crumb:hover { opacity: 1; } #qr.fourchanx-stacked-captcha .fourchanx-captcha-crumb.current { opacity: 1; outline: 2px solid var(--xt-variant-accent, #00c06f); cursor: default; } #qr.fourchanx-stacked-captcha .fourchanx-captcha-crumb.editing { opacity: 1; outline: 3px solid var(--xt-variant-accent, #00c06f); outline-offset: -1px; box-shadow: 0 0 6px color-mix(in srgb, var(--xt-variant-accent, #00c06f) 70%, transparent); } #qr.fourchanx-stacked-captcha .fourchanx-captcha-crumb.active { opacity: 1; outline: 3px solid var(--xt-variant-accent, #00c06f); outline-offset: -1px; box-shadow: 0 0 6px color-mix(in srgb, var(--xt-variant-accent, #00c06f) 70%, transparent); } #qr.fourchanx-stacked-captcha .captcha-root:focus, #qr.fourchanx-stacked-captcha .captcha-root:focus-visible { outline: none; } #qr.fourchanx-stacked-captcha #t-desc img { zoom: 1.5; } #qr.fourchanx-captcha-style-inline #t-ctrl > *, #qr.fourchanx-captcha-style-dots #t-ctrl > * { order: 4; } #qr.fourchanx-captcha-style-inline #t-ctrl > #t-load, #qr.fourchanx-captcha-style-dots #t-ctrl > #t-load { order: 0; } #qr.fourchanx-captcha-style-inline #t-ctrl > .fourchanx-captcha-load-hint, #qr.fourchanx-captcha-style-dots #t-ctrl > .fourchanx-captcha-load-hint { order: 1; } #qr.fourchanx-captcha-style-inline #t-ctrl > .fourchanx-captcha-crumbs, #qr.fourchanx-captcha-style-dots #t-ctrl > .fourchanx-captcha-crumbs { order: 2; } #qr.fourchanx-captcha-style-inline #t-ctrl > .fourchanx-captcha-progress, #qr.fourchanx-captcha-style-dots #t-ctrl > .fourchanx-captcha-progress { order: 3; } #qr.fourchanx-captcha-style-inline .fourchanx-captcha-load-hint:empty, #qr.fourchanx-captcha-style-dots .fourchanx-captcha-load-hint:empty { flex: 0 0 auto; margin-left: 0; min-height: 0; } #qr.fourchanx-captcha-style-inline .fourchanx-captcha-crumbs { flex: 0 1 auto; margin: 0; justify-content: flex-start; } #qr.fourchanx-captcha-style-inline .fourchanx-captcha-crumb { width: 24px; height: 24px; font-size: 11px; } #qr.fourchanx-captcha-style-dots .fourchanx-captcha-crumbs { flex: 0 1 auto; margin: 0 4px 0 auto; gap: 6px; } #qr.fourchanx-captcha-style-dots .fourchanx-captcha-progress { margin-left: 0; } #qr.fourchanx-captcha-style-dots .fourchanx-captcha-crumb--dot { width: 12px; height: 12px; border-radius: 50%; border: 1.5px solid currentColor; overflow: visible; position: relative; } #qr.fourchanx-captcha-style-dots .fourchanx-captcha-crumb--dot.answered { background: var(--xt-variant-accent, #00c06f) !important; border-color: var(--xt-variant-accent, #00c06f); } #qr.fourchanx-captcha-style-dots .fourchanx-captcha-crumb--dot.current { outline: none; border-color: var(--xt-variant-accent, #00c06f); box-shadow: 0 0 0 2px color-mix(in srgb, var(--xt-variant-accent, #00c06f) 40%, transparent); } #qr.fourchanx-captcha-style-dots .fourchanx-captcha-dot-pop { display: none; position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); width: 48px; height: 48px; border: 1px solid currentColor; border-radius: 3px; overflow: hidden; background: #fff; z-index: 5; } #qr.fourchanx-captcha-style-dots .fourchanx-captcha-dot-pop img { width: 100%; height: 100%; display: block; } #qr.fourchanx-captcha-style-dots .fourchanx-captcha-crumb--dot:hover .fourchanx-captcha-dot-pop { display: block; } `; document.head.appendChild(style); } if (!TCaptcha.__fourchanXOriginal) { TCaptcha.__fourchanXOriginal = { setChallenge: TCaptcha.setChallenge, setTaskId: TCaptcha.setTaskId, setTaskNodeContent: TCaptcha.setTaskNodeContent, buildSliderNode: TCaptcha.buildSliderNode, buildNextNode: TCaptcha.buildNextNode, onSliderKeyUp: TCaptcha.onSliderKeyUp, }; } const o = TCaptcha.__fourchanXOriginal; TCaptcha.onSliderKeyUp = function (e) { if (e?.shiftKey && ((e.code === 'Space') || (e.keyCode === 32))) { return; } return o.onSliderKeyUp.call(this, e); }; }, setStacked(enabled, TCaptcha) { const root = $('#qr'); if (!root) { return; } root.classList.toggle('fourchanx-stacked-captcha', enabled); applyCaptchaStyleClass(root, enabled ? this.currentStyle() : null); if (TCaptcha.node) { if (enabled) { TCaptcha.node.style.height = 'auto'; TCaptcha.node.style.overflow = 'visible'; } else { TCaptcha.node.style.height = '145px'; TCaptcha.node.style.overflow = 'hidden'; } } if (TCaptcha.taskNode) { if (enabled) { TCaptcha.taskNode.style.height = 'auto'; TCaptcha.taskNode.style.alignItems = 'flex-start'; } else { TCaptcha.taskNode.style.height = '80px'; TCaptcha.taskNode.style.alignItems = 'center'; } } if (!enabled) { if (TCaptcha.__fourchanXOriginal) { const o = TCaptcha.__fourchanXOriginal; TCaptcha.setChallenge = o.setChallenge; TCaptcha.setTaskId = o.setTaskId; TCaptcha.setTaskNodeContent = o.setTaskNodeContent; TCaptcha.buildSliderNode = o.buildSliderNode; TCaptcha.buildNextNode = o.buildNextNode; TCaptcha.onSliderKeyUp = function (e) { if (e?.shiftKey && ((e.code === 'Space') || (e.keyCode === 32))) { return; } return o.onSliderKeyUp.call(this, e); }; } TCaptcha.__fourchanXStackedEnabled = false; this.cachedButtons = []; this.currentHighlightIndex = -1; this.crumbHighlightIndex = -1; this.updateProgress(); return; } if (TCaptcha.__fourchanXStackedEnabled) { return; } TCaptcha.__fourchanXStackedEnabled = true; this.installStackedKeyHandler(TCaptcha); const o = TCaptcha.__fourchanXOriginal; TCaptcha.setChallenge = function (challenge) { CaptchaT._autoSubmitted = false; if (!challenge?.tasks) { return o.setChallenge.call(this, challenge); } this.challengeIdNode.value = challenge.challenge; this.respNode.value = ''; CaptchaT.answerHistory = []; CaptchaT.editingIndex = null; this.tasks = challenge.tasks; this.setTaskId(0); CaptchaT.createImageGrid(this); }; TCaptcha.setTaskId = function (index) { this.taskId = index; CaptchaT.setState('ready'); CaptchaT.updateProgress(this); }; TCaptcha.setTaskNodeContent = function (text) { const container = $('#t-task', CaptchaT.nodes.container); const state = CaptchaT.messageStateFromText(text); CaptchaT.setState(state); if (container) { container.innerHTML = ''; } CaptchaT.setStatusMessage(text, state); CaptchaT.cachedButtons = []; CaptchaT.currentHighlightIndex = -1; CaptchaT.crumbHighlightIndex = -1; CaptchaT.answerHistory = []; CaptchaT.editingIndex = null; CaptchaT.renderCrumbs(this); CaptchaT.updateProgress(); if (state === 'complete') { CaptchaT.maybeAutoSubmit(); } else { CaptchaT._autoSubmitted = false; } }; TCaptcha.buildSliderNode = function () { const slider = document.createElement('span'); slider.id = 't-slider'; slider.hidden = true; return slider; }; TCaptcha.buildNextNode = function () { const next = document.createElement('span'); next.id = 't-next'; return next; }; if (TCaptcha.tasks?.length) { this.createImageGrid(TCaptcha); } else { this.setState('idle'); this.updateProgress(); this.applyAdaptiveTextColors(); } }, }; class DataBoard { constructor(key, sync, dontClean = false) { this.changes = []; this.onSync = this.onSync.bind(this); this.key = key; this.initData(Conf[this.key]); $.sync(this.key, this.onSync); if (!dontClean) this.clean(); if (!sync) return; var init = () => { $.off(d, '4chanXInitFinished', init); this.sync = sync; }; $.on(d, '4chanXInitFinished', init); } initData(data) { let boards; this.data = data; if (this.data.boards) { let lastChecked; ({ boards, lastChecked } = this.data); this.data['4chan.org'] = { boards, lastChecked }; delete this.data.boards; delete this.data.lastChecked; } return this.data[g.SITE.ID] || (this.data[g.SITE.ID] = { boards: dict() }); } save(change, cb) { change(); this.changes.push(change); return $.get(this.key, { boards: dict() }, (items) => { if (!this.changes.length) { return; } const needSync = ((items[this.key].version || 0) > (this.data.version || 0)); if (needSync) { this.initData(items[this.key]); for (change of this.changes) { change(); } } this.changes = []; this.data.version = (this.data.version || 0) + 1; return $.set(this.key, this.data, () => { if (needSync) { this.sync?.(); } return cb?.(); }); }); } forceSync(cb) { return $.get(this.key, { boards: dict() }, (items) => { if ((items[this.key].version || 0) > (this.data.version || 0)) { this.initData(items[this.key]); for (var change of this.changes) { change(); } this.sync?.(); } return cb?.(); }); } delete({ siteID, boardID, threadID, postID }, cb) { if (!siteID) { siteID = g.SITE.ID; } if (!this.data[siteID]) { return; } this.save(() => { if (postID) { if (!this.data[siteID].boards[boardID]?.[threadID]) { return; } delete this.data[siteID].boards[boardID][threadID][postID]; this.deleteIfEmpty({ siteID, boardID, threadID }); } else if (threadID) { if (!this.data[siteID].boards[boardID]) { return; } delete this.data[siteID].boards[boardID][threadID]; this.deleteIfEmpty({ siteID, boardID }); } else { delete this.data[siteID].boards[boardID]; } }, cb); } deleteIfEmpty({ siteID, boardID, threadID }) { if (!this.data[siteID]) { return; } if (threadID) { if (!Object.keys(this.data[siteID].boards[boardID][threadID]).length) { delete this.data[siteID].boards[boardID][threadID]; this.deleteIfEmpty({ siteID, boardID }); } } else if (!Object.keys(this.data[siteID].boards[boardID]).length) { delete this.data[siteID].boards[boardID]; } } set(data, cb) { this.save(() => { this.setUnsafe(data); }, cb); } setUnsafe({ siteID, boardID, threadID, postID, val }) { if (!siteID) { siteID = g.SITE.ID; } if (!this.data[siteID]) this.data[siteID] = { boards: dict() }; const boards = this.data[siteID].boards; if (postID !== undefined) { let base; const threadKey = threadID; (((base = boards[boardID] || (boards[boardID] = dict())))[threadKey] || (base[threadKey] = dict()))[postID] = val; } else if (threadID !== undefined) { (boards[boardID] || (boards[boardID] = dict()))[threadID] = val; } else { boards[boardID] = val; } } extend({ siteID, boardID, threadID, postID, val }, cb) { this.save(() => { const oldVal = this.get({ siteID, boardID, threadID, postID, defaultValue: dict() }); for (var key in val) { var subVal = val[key]; if (typeof subVal === 'undefined') { delete oldVal[key]; } else { oldVal[key] = subVal; } } this.setUnsafe({ siteID, boardID, threadID, postID, val: oldVal }); }, cb); } setLastChecked(key = 'lastChecked') { this.save(() => { this.data[key] = Date.now(); }); } get({ siteID, boardID, threadID, postID, defaultValue }) { let board, val; if (!siteID) { siteID = g.SITE.ID; } if (board = this.data[siteID]?.boards[boardID]) { let thread; if (threadID == null) { if (postID != null) { for (thread = 0; thread < board.length; thread++) { if (postID in thread) { val = thread[postID]; break; } } } else { val = board; } } else if (thread = board[threadID]) { val = (postID != null) ? thread[postID] : thread; } } return val || defaultValue; } clean() { let boardID, middle; const siteID = g.SITE.ID; for (boardID in this.data[siteID].boards) { this.deleteIfEmpty({ siteID, boardID }); } const now = Date.now(); if (now - (2 * HOUR) >= ((middle = this.data[siteID].lastChecked || 0)) || middle > now) { this.data[siteID].lastChecked = now; for (boardID in this.data[siteID].boards) { this.ajaxClean(boardID); } } } ajaxClean(boardID) { const that = this; const siteID = g.SITE.ID; const threadsList = g.SITE.urls.threadsListJSON?.({ siteID, boardID }); if (!threadsList) { return; } $.cache(threadsList, function () { if (this.status !== 200) { return; } const archiveList = g.SITE.urls.archiveListJSON?.({ siteID, boardID }); if (!archiveList) return that.ajaxCleanParse(boardID, this.response); const response1 = this.response; $.cache(archiveList, function () { if ((this.status !== 200) && (!!g.SITE.archivedBoardsKnown || (this.status !== 404))) { return; } that.ajaxCleanParse(boardID, response1, this.response); }); }); } ajaxCleanParse(boardID, response1, response2) { let board, ID; const siteID = g.SITE.ID; if (!(board = this.data[siteID].boards[boardID])) return; const threads = dict(); if (response1) { for (var page of response1) { for (var thread of page.threads) { ID = thread.no; if (ID in board) { threads[ID] = board[ID]; } } } } if (response2) { for (ID of response2) { if (ID in board) threads[ID] = board[ID]; } } this.data[siteID].boards[boardID] = threads; this.deleteIfEmpty({ siteID, boardID }); $.set(this.key, this.data); } onSync(data) { if (!data) { return; } if ((data.version || 0) <= (this.data.version || 0)) { return; } this.initData(data); this.sync?.(); } } DataBoard.keys = [ 'hiddenThreads', 'hiddenPosts', 'hiddenPosterIds', 'lastReadPosts', 'yourPosts', 'watchedThreads', 'watcherLastModified', 'customTitles', 'sounds', ]; class SimpleDict { constructor() { this.keys = []; } push(key, data) { key = `${key}`; if (!this[key]) { this.keys.push(key); } this[key] = data; } insert(key, data, compare = (lastKey, key) => (+lastKey) < (+key)) { const keyString = key.toString(); if (keyString in this) { this[keyString] = data; return this.keys.indexOf(keyString); } const length = this.keys.length; if (!length || compare(this.lastKey(), key)) { this.push(key, data); return length; } let indexOfNext = this.keys.findIndex(k => !compare(k, key)); if (indexOfNext === -1) { this.push(key, data); } else { this[keyString] = data; this.keys.splice(indexOfNext, 0, keyString); } return indexOfNext; } insertAt(key, index, data) { this[key] = data; this.keys.splice(index, 0, key); } rm(key) { let i; key = `${key}`; if ((i = this.keys.indexOf(key)) !== -1) { this.keys.splice(i, 1); delete this[key]; } } forEach(fn) { for (var key of this.keys) { fn(this[key]); } } get(key) { if (key === 'keys') { return undefined; } else { return $.getOwn(this, key); } } lastKey() { return this.keys[this.keys.length - 1]; } last() { return this.keys.length ? this[this.keys.length - 1] : undefined; } } class Thread { toString() { return this.ID; } constructor(ID, board) { this.board = board; this.ID = +ID; this.threadID = this.ID; this.boardID = this.board.ID; this.siteID = g.SITE.ID; this.fullID = `${this.board}.${this.ID}`; this.posts = new SimpleDict(); this.isDead = false; this.isHidden = false; this.isSticky = false; this.isClosed = false; this.isArchived = false; this.postLimit = false; this.fileLimit = false; this.lastPost = 0; this.ipCount = undefined; this.json = null; this.OP = null; this.catalogView = null; this.nodes = { root: null }; this.board.threads.push(this.ID, this); g.threads.push(this.fullID, this); } setPage(pageNum) { let icon; const { info, reply } = this.OP.nodes; if (!(icon = $('.page-num', info))) { icon = $.el('span', { className: 'page-num' }); $.replace(reply.parentNode.previousSibling, [$.tn(' '), icon, $.tn(' ')]); } icon.title = `This thread is on page ${pageNum} in the original index.`; icon.textContent = `[${pageNum}]`; if (this.catalogView) { return this.catalogView.nodes.pageCount.textContent = pageNum; } } setCount(type, count, reachedLimit) { if (!this.catalogView) { return; } const el = this.catalogView.nodes[`${type}Count`]; el.textContent = count; return (reachedLimit ? $.addClass : $.rmClass)(el, 'warning'); } setStatus(type, status) { const name = `is${type}`; if (this[name] === status) { return; } this[name] = status; if (!this.OP) { return; } this.setIcon('Sticky', this.isSticky); this.setIcon('Closed', this.isClosed && !this.isArchived); return this.setIcon('Archived', this.isArchived); } setIcon(type, status) { const typeLC = type.toLowerCase(); let icon = $(`.${typeLC}Icon`, this.OP.nodes.info); if (!!icon === status) { return; } if (!status) { $.rm(icon.previousSibling); $.rm(icon); if (this.catalogView) { $.rm($(`.${typeLC}Icon`, this.catalogView.nodes.icons)); } return; } icon = $.el('img', { src: `${g.SITE.Build.staticPath}${typeLC}${g.SITE.Build.gifIcon}`, alt: type, title: type, className: `${typeLC}Icon retina` }); if (g.BOARD.ID === 'f') { icon.style.cssText = 'height: 18px; width: 18px;'; } const root = (type !== 'Sticky') && this.isSticky ? $('.stickyIcon', this.OP.nodes.info) : $('.page-num', this.OP.nodes.info) || this.OP.nodes.quote; $.after(root, [$.tn(' '), icon]); if (!this.catalogView) { return; } return ((type === 'Sticky') && this.isClosed ? $.prepend : $.add)(this.catalogView.nodes.icons, icon.cloneNode()); } kill() { return this.isDead = true; } collect() { let n = 0; this.posts.forEach(function (post) { if (post.clones.length) { return n++; } else { return post.collect(); } }); if (!n) { g.threads.rm(this.fullID); return this.board.threads.rm(this); } } } class CatalogThread { toString() { return this.ID; } constructor(root, thread) { this.thread = thread; this.ID = this.thread.ID; this.board = this.thread.board; const { post } = this.thread.OP.nodes; this.nodes = { root, thumb: $('.catalog-thumb', post), icons: $('.catalog-icons', post), postCount: $('.post-count', post), fileCount: $('.file-count', post), pageCount: $('.page-count', post), replies: null }; this.thread.catalogView = this; } } var nextSettingsDiff = { "_comment": "GENERATED by tools/gen-next-settings-diff.js — do not edit by hand.", "_generatedFrom": { "next": "src/config/Config.ts", "xt": "tools/baseline/xt-Config.js (4chan-XT @ ee5a63846)" }, "counts": { "xt": 259, "next": 431, "added": 166, "changed": 3 }, "added": [ "Allow Browser Autofill", "Auto-close Tags", "Catalog Highlight Border Width", "Catalog Highlight Filter Background", "Catalog Highlight Filter Border Style", "Catalog Highlight Filter Border Width", "Catalog Highlight Filter Color", "Catalog Highlight Filter Dead Link Color", "Catalog Highlight Filter Glow", "Catalog Highlight Filter Glow Intensity", "Catalog Highlight Filter Link Color", "Catalog Highlight Filter Location", "Catalog Highlight Filter Opacity", "Catalog Highlight Filter Posts", "Catalog Highlight Filter Quote Color", "Catalog Highlight Filter Subject Color", "Catalog Highlight Filter Text Color", "Catalog Highlight Filter Text Mode", "Catalog Highlight Own Background", "Catalog Highlight Own Border Style", "Catalog Highlight Own Border Width", "Catalog Highlight Own Color", "Catalog Highlight Own Dead Link Color", "Catalog Highlight Own Glow", "Catalog Highlight Own Glow Intensity", "Catalog Highlight Own Link Color", "Catalog Highlight Own Location", "Catalog Highlight Own Opacity", "Catalog Highlight Own Posts", "Catalog Highlight Own Quote Color", "Catalog Highlight Own Subject Color", "Catalog Highlight Own Text Color", "Catalog Highlight Own Text Mode", "Catalog Highlight Watched Background", "Catalog Highlight Watched Border Style", "Catalog Highlight Watched Border Width", "Catalog Highlight Watched Color", "Catalog Highlight Watched Dead Link Color", "Catalog Highlight Watched Glow", "Catalog Highlight Watched Glow Intensity", "Catalog Highlight Watched Link Color", "Catalog Highlight Watched Location", "Catalog Highlight Watched Opacity", "Catalog Highlight Watched Quote Color", "Catalog Highlight Watched Subject Color", "Catalog Highlight Watched Text Color", "Catalog Highlight Watched Text Mode", "Catalog Highlight Watched Threads", "Clear Name and Options After Posting", "Comment Preview", "Comment Preview Attach Location", "Comment Preview Catalog Behavior", "Comment Preview Default Mode", "Comment Preview Inline Behavior", "Comment Preview Last Mode", "Comment Preview Position", "Comment Preview Remember Float Position", "Comment Preview Thread Behavior", "Convert X to xcancel", "Convert YouTube to yewtu.be", "Dead Link Text Color", "Dogiri Autosave", "Dogiri Editor", "Dogiri Filmstrip", "Dogiri Oversize Render Warning", "Dogiri Timeline Size", "Download All Media", "Download all media", "Dump List Remove File First", "Enable Catalog Highlights", "Enable Thread Highlights", "Fetch Ghost Posts", "Gallery Columns", "Gallery Thumbnails Position", "Grid Thumbnails", "Group Hidden Threads By Filter", "Hide Board Banner", "Hide Original Post Form", "Hide thread (catalog click)", "Highlight Ghost Background", "Highlight Ghost Border Style", "Highlight Ghost Color", "Highlight Ghost Dead Link Color", "Highlight Ghost Edge Width", "Highlight Ghost Link Color", "Highlight Ghost Opacity", "Highlight Ghost Posts", "Highlight Ghost Quote Color", "Highlight Ghost Text Auto", "Highlight Ghost Text Color", "Highlight Ghost Text Mode", "Highlight Own Background", "Highlight Own Border Style", "Highlight Own Color", "Highlight Own Dead Link Color", "Highlight Own Edge Width", "Highlight Own Link Color", "Highlight Own Opacity", "Highlight Own Quote Color", "Highlight Own Text Auto", "Highlight Own Text Color", "Highlight Own Text Mode", "Highlight You Background", "Highlight You Border Style", "Highlight You Color", "Highlight You Dead Link Color", "Highlight You Edge Width", "Highlight You Link Color", "Highlight You Opacity", "Highlight You Quote Color", "Highlight You Text Auto", "Highlight You Text Color", "Highlight You Text Mode", "Icon Set", "Link Text Color", "Persistent Download Media", "QR Drafts", "Quote Text Color", "Scroll Marker Ghost Color", "Scroll Marker Ghost Match Highlight", "Scroll Marker Ghost Opacity", "Scroll Marker Match Highlights", "Scroll Marker Own Color", "Scroll Marker Own Match Highlight", "Scroll Marker Own Opacity", "Scroll Marker Unread Color", "Scroll Marker Unread Opacity", "Scroll Marker You Color", "Scroll Marker You Match Highlight", "Scroll Marker You Opacity", "Scrollbar Mark Ghost Posts", "Scrollbar Mark Own Posts", "Scrollbar Mark Quotes You", "Scrollbar Mark Unread Line", "Scrollbar Marker Hover Preview", "Scrollbar Marker Position", "Scrollbar Markers", "Settings Descriptions as Tooltips", "Settings Menu Layout", "Show Comment Preview Header Icon", "Show Mark All Read Icon", "Show Mark Thread Read Icons", "Show OP Thumbnails", "Show QR Drafts Icon", "Show Threads With Yous", "Show Undo Button", "Spoiler Mode", "Stacked TCaptcha", "Stacked TCaptcha Style", "Strip Video Audio", "Text Color", "Thread Highlight Edge Width", "Thread Watcher Attach Controls", "Thread Watcher Attach Location", "Thread Watcher Attached", "Thread Watcher Max Height", "Thread Watcher Max Width", "Thread Watcher Sort", "Thread Watcher Sort 2", "Thread Watcher Thumbnail Hover", "Thread Watcher Thumbnail Preview Size", "Thread Watcher Thumbnail Size", "Toggle comment preview", "Toggle drafts", "Undo last hide/filter", "Video Duration Badge", "Watch (catalog click)" ], "changed": [ "Replace Thumbnails", "Scroll Markers", "Thread Updater" ] } ; const NEXT_ADDED$1 = new Set(nextSettingsDiff.added); const NEXT_CHANGED$1 = new Set(nextSettingsDiff.changed); const dialog = function (id, properties) { const el = $.el('div', { className: 'dialog', id }); $.extend(el, properties); el.style.cssText = Conf[`${id}.position`]; const move = $('.move', el); $.on(move, 'touchstart mousedown', dragstart); for (var child of move.children) { if (!child.tagName) { continue; } $.on(child, 'touchstart mousedown', e => e.stopPropagation()); } return el; }; const threadWatcherAttached = () => Conf['Thread Watcher Attach Controls'] !== false && Conf['Thread Watcher Attached']; var Menu$1 = (function () { let currentMenu = undefined; let lastToggledButton = undefined; let anchorPoint = null; let menuNode = null; let menuBackdrop = null; Menu$1 = class Menu { static initClass() { currentMenu = null; lastToggledButton = null; } constructor(type) { this.type = null; this.entries = []; this.menu = null; this.setPosition = this.setPosition.bind(this); this.close = this.close.bind(this); this.keybinds = this.keybinds.bind(this); this.onFocus = this.onFocus.bind(this); this.addEntry = this.addEntry.bind(this); this.type = type; $.on(d, 'AddMenuEntry', ({ detail }) => { if (detail.type !== this.type) { return; } delete detail.open; return this.addEntry(detail); }); this.entries = []; } makeMenu() { if (!menuNode) { menuNode = $.el('div', { className: 'dialog menu-hidden', id: 'menu', tabIndex: 0 }); $.on(menuNode, 'click', e => e.stopPropagation()); $.on(menuNode, 'keydown', e => currentMenu?.keybinds(e)); menuBackdrop = $.el('div', { id: 'menu-backdrop', className: 'menu-hidden' }); $.add(d.body, [menuBackdrop, menuNode]); } menuNode.dataset.type = this.type; return menuNode; } toggle(e, button, data) { e.preventDefault(); e.stopPropagation(); if (currentMenu) { const previousButton = lastToggledButton; currentMenu.close(); if (previousButton === button) { return; } } if (!this.entries.length) { return; } return this.open(button, data); } toggleAtPoint(e, point, data) { e.preventDefault(); e.stopPropagation(); if (currentMenu) { currentMenu.close(); } if (!this.entries.length) { return; } anchorPoint = { x: point.x + window.scrollX, y: point.y + window.scrollY }; return this.open(null, data); } open(button, data) { let entry; const menu = (this.menu = this.makeMenu()); currentMenu = this; lastToggledButton = button; this.entries.sort((first, second) => first.order - second.order); $.rmAll(menu); for (entry of this.entries) { this.insertEntry(entry, menu, data); } for (const openEl of $$('.submenu-open', menu)) { $.rmClass(openEl, 'submenu-open'); } this.tagNextEntries(menu); if (lastToggledButton) { $.addClass(lastToggledButton, 'active'); } $.on(d, 'click CloseMenu', this.close); $.on(d, 'scroll', this.setPosition); $.on(window, 'resize', this.setPosition); const anchorHeader = doc.classList.contains('xt-mobile') && !!button?.closest('#header-bar'); menu.classList.toggle('menu-anchor-header', anchorHeader); menuBackdrop?.classList.toggle('menu-anchor-header', anchorHeader); const anchorPopover = doc.classList.contains('xt-mobile') && !!anchorPoint; menu.classList.toggle('menu-anchor-point', anchorPopover); menuBackdrop?.classList.toggle('menu-anchor-point', anchorPopover); $.rmClass(menu, 'menu-hidden'); $.rmClass(menuBackdrop, 'menu-hidden'); this.setPosition(); entry = $('.entry', menu); this.focus(entry); return menu.focus(); } setPosition() { if (doc.classList.contains('xt-mobile')) { if (!anchorPoint) { $.extend(this.menu.style, { top: '', right: '', bottom: '', left: '' }); return; } const mWidth = this.menu.offsetWidth; const mHeight = this.menu.offsetHeight; const vv = window.visualViewport; const vw = vv?.width ?? doc.clientWidth; const vh = vv?.height ?? doc.clientHeight; const ox = vv?.offsetLeft ?? 0; const oy = vv?.offsetTop ?? 0; const margin = 8; const x = anchorPoint.x - window.scrollX; const y = anchorPoint.y - window.scrollY; let left = (x + mWidth + margin > ox + vw) ? x - mWidth : x; let top = (y + mHeight + margin > oy + vh) ? y - mHeight : y; left = Math.max(ox + margin, Math.min(left, ox + vw - mWidth - margin)); top = Math.max(oy + margin, Math.min(top, oy + vh - mHeight - margin)); $.extend(this.menu.style, { top: `${top}px`, left: `${left}px`, right: '', bottom: '' }); return; } const mRect = this.menu.getBoundingClientRect(); let bRect; if (anchorPoint) { const x = anchorPoint.x - window.scrollX; const y = anchorPoint.y - window.scrollY; bRect = { top: y, bottom: y, left: x, right: x, height: 0 }; } else { bRect = lastToggledButton.getBoundingClientRect(); } const cHeight = doc.clientHeight; const cWidth = doc.clientWidth; const [top, bottom] = (bRect.top + bRect.height + mRect.height) < cHeight ? [`${bRect.bottom}px`, ''] : ['', `${cHeight - bRect.top}px`]; const [left, right] = (bRect.left + mRect.width) < cWidth ? [`${bRect.left}px`, ''] : ['', `${cWidth - bRect.right}px`]; $.extend(this.menu.style, { top, right, bottom, left }); return this.menu.classList.toggle('left', right); } insertEntry(entry, parent, data) { let submenu; if (typeof entry.open === 'function') { try { if (!entry.open(data)) { return; } } catch (err) { Main.handleErrors({ message: `Error in building the ${this.type} menu.`, error: err }); return; } } $.add(parent, entry.el); if (!entry.subEntries) { return; } if (submenu = $('.submenu', entry.el)) { $.rm(submenu); } submenu = $.el('div', { className: 'dialog submenu' }); for (var subEntry of entry.subEntries) { this.insertEntry(subEntry, submenu, data); } $.add(entry.el, submenu); } tagNextEntries(menu) { for (const el of $$('[data-next-status]', menu)) delete el.dataset.nextStatus; const statusOf = (key) => NEXT_ADDED$1.has(key) ? 'added' : NEXT_CHANGED$1.has(key) ? 'changed' : ''; const own = new Set(); const markOwn = (start, status) => { const entry = start.closest('.entry'); if (!entry || !menu.contains(entry)) return; own.add(entry); if (entry.dataset.nextStatus !== 'added') entry.dataset.nextStatus = status; }; for (const input of $$('input[name], select[name], textarea[name]', menu)) { const status = statusOf(input.getAttribute('name') || ''); if (status) markOwn(input, status); } for (const el of $$('[data-next-key]', menu)) { const status = statusOf(el.dataset.nextKey || ''); if (status) markOwn(el, status); } for (const entry of own) { let node = entry.parentElement ? entry.parentElement.closest('.entry') : null; while (node && menu.contains(node)) { if (!own.has(node) && !node.dataset.nextStatus) node.dataset.nextStatus = 'changed'; node = node.parentElement ? node.parentElement.closest('.entry') : null; } } for (const entry of own) { if (entry.dataset.nextStatus !== 'added' || !entry.classList.contains('has-submenu')) continue; for (const child of $$('[data-next-status]', entry)) delete child.dataset.nextStatus; } } close() { if (!menuNode || !(lastToggledButton || anchorPoint)) { return; } $.addClass(menuNode, 'menu-hidden'); $.addClass(menuBackdrop, 'menu-hidden'); delete this.menu; if (lastToggledButton) { $.rmClass(lastToggledButton, 'active'); } currentMenu = null; lastToggledButton = null; anchorPoint = null; $.off(d, 'click scroll CloseMenu', this.close); $.off(d, 'scroll', this.setPosition); $.off(window, 'resize', this.setPosition); return $.event('MenuClosed'); } findNextEntry(entry, direction) { const entries = [...entry.parentNode.children]; entries.sort((first, second) => first.style.order - second.style.order); return entries[entries.indexOf(entry) + direction]; } keybinds(e) { let subEntry; let next, submenu; let entry = $('.focused', this.menu); while ((subEntry = $('.focused', entry))) { entry = subEntry; } switch (e.keyCode) { case 27: lastToggledButton?.focus(); this.close(); break; case 13: case 32: entry.click(); break; case 38: if (next = this.findNextEntry(entry, -1)) { this.focus(next); } break; case 40: if (next = this.findNextEntry(entry, +1)) { this.focus(next); } break; case 39: if ((submenu = $('.submenu', entry)) && (next = submenu.firstElementChild)) { let nextPrev; while ((nextPrev = this.findNextEntry(next, -1))) { next = nextPrev; } this.focus(next); } break; case 37: if (next = $.x('parent::*[contains(@class,"submenu")]/parent::*', entry)) { this.focus(next); } break; default: return; } e.preventDefault(); return e.stopPropagation(); } onFocus(e) { e.stopPropagation(); return this.focus(e.target); } focus(entry) { let focused, submenu; while ((focused = $.x('parent::*/child::*[contains(@class,"focused")]', entry))) { $.rmClass(focused, 'focused'); } for (focused of $$('.focused', entry)) { $.rmClass(focused, 'focused'); } $.addClass(entry, 'focused'); if (!(submenu = $('.submenu', entry))) { return; } const sRect = submenu.getBoundingClientRect(); const eRect = entry.getBoundingClientRect(); const cHeight = doc.clientHeight; const cWidth = doc.clientWidth; const [top, bottom] = (eRect.top + sRect.height) < cHeight ? ['0px', 'auto'] : ['auto', '0px']; const container = entry.parentNode; const preferLeft = container && container.classList.contains('submenu') ? container.classList.contains('submenu-left') : this.menu.classList.contains('left'); const fitsRight = (eRect.right + sRect.width) < (cWidth - 150); const fitsLeft = (eRect.left - sRect.width) > 4; const openLeft = preferLeft ? (fitsLeft || !fitsRight) : !fitsRight; submenu.classList.toggle('submenu-left', openLeft); const [left, right] = openLeft ? ['auto', '100%'] : ['100%', 'auto']; const { style } = submenu; style.top = top; style.bottom = bottom; style.left = left; return style.right = right; } addEntry(entry) { this.parseEntry(entry); return this.entries.push(entry); } parseEntry(entry) { const { el, subEntries } = entry; $.addClass(el, 'entry'); $.on(el, 'focus mouseover', this.onFocus); el.style.order = entry.order || 100; if (!subEntries) { return; } $.addClass(el, 'has-submenu'); $.on(el, 'click', (e) => { if (!doc.classList.contains('xt-mobile')) { return; } const submenu = $('.submenu', el); if (submenu && submenu.contains(e.target)) { return; } const open = !el.classList.contains('submenu-open'); if (open && el.parentNode) { for (const sibling of el.parentNode.children) { if (sibling !== el) { $.rmClass(sibling, 'submenu-open'); } } } el.classList.toggle('submenu-open', open); }); for (var subEntry of subEntries) { this.parseEntry(subEntry); } const span = $.el('span', { className: 'menu-indicator' }); Icon.set(span, 'caretRight'); $.add(el, span); } }; Menu$1.initClass(); return Menu$1; })(); var dragstart = function (e) { let isTouching; if ((e.type === 'mousedown') && (e.button !== 0)) { return; } e.preventDefault(); if (isTouching = e.type === 'touchstart') { e = e.changedTouches[e.changedTouches.length - 1]; } let el = $.x('ancestor::div[contains(@class,"dialog")][1]', this); if (el.id === 'thread-watcher' && threadWatcherAttached()) { const qr = $.id('qr'); if (qr && !qr.hidden) { el = qr; } } const rect = el.getBoundingClientRect(); const screenHeight = doc.clientHeight; const screenWidth = doc.clientWidth; const o = { id: el.id, style: el.style, dx: e.clientX - rect.left, dy: e.clientY - rect.top, height: screenHeight - rect.height, width: screenWidth - rect.width, screenHeight, screenWidth, isTouching, topBorder: 0, bottomBorder: 0, identifier: null, move: null, up: null }; [o.topBorder, o.bottomBorder] = Conf['Header auto-hide'] || !Conf['Fixed Header'] ? [0, 0] : Conf['Bottom Header'] ? [0, Header.bar.getBoundingClientRect().height] : [Header.bar.getBoundingClientRect().height, 0]; if (isTouching) { o.identifier = e.identifier; o.move = touchmove.bind(o); o.up = touchend.bind(o); $.on(d, 'touchmove', o.move); return $.on(d, 'touchend touchcancel', o.up); } else { o.move = drag.bind(o); o.up = dragend.bind(o); $.on(d, 'mousemove', o.move); return $.on(d, 'mouseup', o.up); } }; var touchmove = function (e) { for (var touch of e.changedTouches) { if (touch.identifier === this.identifier) { drag.call(this, touch); return; } } }; var drag = function (e) { const { clientX, clientY } = e; let left = clientX - this.dx; left = left < 10 ? 0 : (this.width - left) < 10 ? '' : ((left / this.screenWidth) * 100) + '%'; let top = clientY - this.dy; top = top < (10 + this.topBorder) ? this.topBorder + 'px' : (this.height - top) < (10 + this.bottomBorder) ? '' : ((top / this.screenHeight) * 100) + '%'; const right = left === '' ? 0 : ''; const bottom = top === '' ? this.bottomBorder + 'px' : ''; const { style } = this; style.left = left; style.right = right; style.top = top; style.bottom = bottom; if (this.id === 'qr') { $.event('4chanXQRMove', { dragging: true }); } }; var touchend = function (e) { for (var touch of e.changedTouches) { if (touch.identifier === this.identifier) { dragend.call(this); return; } } }; var dragend = function () { if (this.isTouching) { $.off(d, 'touchmove', this.move); $.off(d, 'touchend touchcancel', this.up); } else { $.off(d, 'mousemove', this.move); $.off(d, 'mouseup', this.up); } if (this.id === 'thread-watcher' && threadWatcherAttached()) { return; } if (this.style.length === 2) { $.set(`${this.id}.position`, this.style.cssText); } else { const { left, right, top, bottom } = this.style; let position = ''; if (left) position += `left:${left};`; if (right) position += `right:${right};`; if (top) position += `top:${top};`; if (bottom) position += `bottom:${bottom};`; $.set(`${this.id}.position`, position); } if (this.id === 'thread-watcher') { $.event('4chanXDragend', { id: this.id }); } if (this.id === 'qr') { $.event('4chanXQRMove', { dragging: false }); } }; const hoverstart = function ({ root, el, latestEvent, endEvents, height, width, cb, noRemove }) { const rect = root.getBoundingClientRect(); const o = { root, el, style: el.style, isImage: ['IMG', 'VIDEO'].includes(el.nodeName), cb, endEvents, latestEvent, clientHeight: doc.clientHeight, clientWidth: doc.clientWidth, height, width, noRemove, clientX: (rect.left + rect.right) / 2, clientY: (rect.top + rect.bottom) / 2, hover: null, hoverend: null, workaround: null }; o.hover = hover.bind(o); o.hoverend = hoverend.bind(o); o.hover(o.latestEvent); new MutationObserver(function () { if (el.parentNode) { return o.hover(o.latestEvent); } }).observe(el, { childList: true }); $.on(root, endEvents, o.hoverend); if ($.x('ancestor::div[contains(@class,"inline")][1]', root)) { $.on(d, 'keydown', o.hoverend); } $.on(root, 'mousemove', o.hover); o.workaround = function (e) { if (!root.contains(e.target)) { return o.hoverend(e); } }; return $.on(doc, 'mousemove', o.workaround); }; hoverstart.padding = 25; var hover = function (e) { this.latestEvent = e; const height = (this.height || this.el.offsetHeight) + hoverstart.padding; const width = (this.width || this.el.offsetWidth); const { clientX, clientY } = Conf['Follow Cursor'] ? e : this; const top = this.isImage ? Math.max(0, (clientY * (this.clientHeight - height)) / this.clientHeight) : Math.max(0, Math.min(this.clientHeight - height, clientY - 120)); let threshold = this.clientWidth / 2; if (!this.isImage) { threshold = Math.max(threshold, this.clientWidth - 400); } let marginX = (clientX <= threshold ? clientX : this.clientWidth - clientX) + 45; if (this.isImage) { marginX = Math.min(marginX, this.clientWidth - width); } marginX += 'px'; const [left, right] = clientX <= threshold ? [marginX, ''] : ['', marginX]; const { style } = this; style.top = top + 'px'; style.left = left; return style.right = right; }; var hoverend = function (e) { if (((e.type === 'keydown') && (e.keyCode !== 13)) || (e.target.nodeName === "TEXTAREA")) { return; } if (!this.noRemove) { $.rm(this.el); } $.off(this.root, this.endEvents, this.hoverend); $.off(d, 'keydown', this.hoverend); $.off(this.root, 'mousemove', this.hover); $.off(doc, 'mousemove', this.workaround); if (this.cb) { return this.cb.call(this); } }; const checkbox = function (name, text, checked) { if (checked == null) { checked = Conf[name]; } const label = $.el('label'); const input = $.el('input', { type: 'checkbox', name, checked }); $.add(label, [input, $.tn(` ${text}`)]); return label; }; const UI = { dialog, Menu: Menu$1, hover: hoverstart, checkbox }; var Nav = { haveExtra: false, init() { const mobile = resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice()); switch (g.VIEW) { case 'index': if (!Conf['Index Navigation'] && !mobile) { return; } break; case 'thread': if (!Conf['Reply Navigation'] && !mobile) { return; } break; default: return; } const span = $.el('span', { id: 'navlinks' }); const prev = $.el('a', { textContent: '▲', className: 'navlinks-navlink navlink-prev', href: 'javascript:;' }); const next = $.el('a', { textContent: '▼', className: 'navlinks-navlink navlink-next', href: 'javascript:;' }); Icon.set(prev, 'arrowUpLong'); Icon.set(next, 'arrowDownLong'); $.on(prev, 'click', this.prev); $.on(next, 'click', this.next); $.add(span, [prev, $.tn(' '), next]); Nav.initDock(span); var append = function () { $.off(d, '4chanXInitFinished', append); return $.add(d.body, span); }; return $.on(d, '4chanXInitFinished', append); }, initDock(span) { let startX = 0; let startY = 0; let tracking = false; let suppressClick = false; let idleTimer = 0; const restartIdle = function () { clearTimeout(idleTimer); idleTimer = window.setTimeout(() => $.addClass(span, 'navlinks-docked'), 2000); }; if (g.VIEW === 'thread') { $.addClass(span, 'navlinks-docked'); } else { restartIdle(); } $.on(span, 'pointerdown', function (e) { startX = e.clientX; startY = e.clientY; tracking = true; suppressClick = false; if (!$.hasClass(span, 'navlinks-docked')) { restartIdle(); } }); $.on(span, 'pointermove', function (e) { if (!tracking || !doc.classList.contains('xt-mobile') || $.hasClass(span, 'navlinks-docked')) { return; } if (((e.clientX - startX) > 30) && (Math.abs(e.clientY - startY) < 40)) { tracking = false; suppressClick = true; clearTimeout(idleTimer); $.addClass(span, 'navlinks-docked'); } }); $.on(span, 'pointerup', () => tracking = false); span.addEventListener('click', function (e) { if (suppressClick) { suppressClick = false; } else if ($.hasClass(span, 'navlinks-docked')) { $.rmClass(span, 'navlinks-docked'); restartIdle(); } else { return; } e.preventDefault(); e.stopPropagation(); }, true); }, prev() { if (g.VIEW === 'thread') { return window.scrollTo(0, 0); } else { return Nav.scroll(-1); } }, next() { if (g.VIEW === 'thread') { return window.scrollTo(0, d.body.scrollHeight); } else { return Nav.scroll(+1); } }, getThread() { if (g.VIEW === 'thread') { return g.threads.get(`${g.BOARD}.${g.THREADID}`).nodes.root; } if ($.hasClass(doc, 'catalog-mode')) { return; } for (var threadRoot of $$(g.SITE.selectors.thread)) { var thread = Get.threadFromRoot(threadRoot); if (!thread) { continue; } if (thread.isHidden && !thread.stub) { continue; } if (Header.getTopOf(threadRoot) >= -threadRoot.getBoundingClientRect().height) { return threadRoot; } } }, scroll(delta) { let next; d.activeElement?.blur(); let thread = Nav.getThread(); if (!thread) { return; } const axis = delta === +1 ? 'following' : 'preceding'; if (next = $.x(`${axis}-sibling::${g.SITE.xpath.thread}[not(@hidden)][1]`, thread)) { const top = Header.getTopOf(thread); if (((delta === +1) && (top < 5)) || ((delta === -1) && (top > -5))) { thread = next; } } const extra = (Header.getTopOf(thread) + doc.clientHeight) - d.body.getBoundingClientRect().bottom; if (extra > 0) { d.body.style.marginBottom = `${extra}px`; } Header.scrollTo(thread); if ((extra > 0) && !Nav.haveExtra) { Nav.haveExtra = true; return $.on(d, 'scroll', Nav.removeExtra); } }, removeExtra() { const extra = doc.clientHeight - d.body.getBoundingClientRect().bottom; if (extra > 0) { return d.body.style.marginBottom = `${extra}px`; } else { d.body.style.marginBottom = ''; delete Nav.haveExtra; return $.off(d, 'scroll', Nav.removeExtra); } } }; var Volume = { inputs: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || (!Conf['Image Expansion'] && !Conf['Image Hover'] && !Conf['Image Hover in Catalog'] && !Conf['Gallery'])) { return; } $.sync('Allow Sound', function (x) { Conf['Allow Sound'] = x; if (Volume.inputs) Volume.inputs.unmute.checked = x; }); $.sync('Default Volume', function (x) { Conf['Default Volume'] = x; if (Volume.inputs) Volume.inputs.volume.value = x; }); if (Conf['Mouse Wheel Volume']) { Callbacks.Post.push({ name: 'Mouse Wheel Volume', cb: this.node }); } if (g.SITE.noAudio?.(g.BOARD)) { return; } if (Conf['Mouse Wheel Volume']) { Callbacks.CatalogThread.push({ name: 'Mouse Wheel Volume', cb: this.catalogNode }); } const unmuteEntry = UI.checkbox('Allow Sound', 'Allow Sound'); unmuteEntry.title = String(Config.main['Images and Videos']['Allow Sound'][1]); const volumeEntry = $.el('label', { title: 'Default volume for videos.' }); $.extend(volumeEntry, { innerHTML: " Volume" }); this.inputs = { unmute: unmuteEntry.firstElementChild, volume: volumeEntry.firstElementChild }; $.on(this.inputs.unmute, 'change', $.cb.checked); $.on(this.inputs.volume, 'change', $.cb.value); Header.menu.addEntry({ el: unmuteEntry, order: 200 }); return Header.menu.addEntry({ el: volumeEntry, order: 201 }); }, setup(video) { video.muted = !Conf['Allow Sound']; video.volume = Conf['Default Volume']; return $.on(video, 'volumechange', Volume.change); }, change() { const { muted, volume } = this; const items = { 'Allow Sound': !muted, 'Default Volume': volume }; for (var key in items) { var val = items[key]; if (Conf[key] === val) { delete items[key]; } } $.set(items); $.extend(Conf, items); if (Volume.inputs) { Volume.inputs.unmute.checked = !muted; return Volume.inputs.volume.value = volume; } }, node() { if (g.SITE.noAudio?.(this.board)) { return; } for (var file of this.files) { if (file.isVideo) { if (file.thumb) { $.on(file.thumb, 'wheel', Volume.wheel.bind(Header.hover)); } $.on(($('.file-info', file.text) || file.link), 'wheel', Volume.wheel.bind(file.thumbLink)); } } }, catalogNode() { const file = this.thread.OP.files[0]; if (!file?.isVideo) { return; } return $.on(this.nodes.thumb, 'wheel', Volume.wheel.bind(Header.hover)); }, wheel(e) { let el; if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) { return; } if (!(el = $('video:not([data-md5])', this))) { return; } if (el.muted || !$.hasAudio(el)) { return; } let volume = el.volume + 0.1; if (e.deltaY < 0) { volume *= 1.1; } if (e.deltaY > 0) { volume /= 1.1; } el.volume = $.minmax(volume - 0.1, 0, 1); return e.preventDefault(); } }; var ImageCommon = { cache: null, pause(video) { if (video.nodeName !== 'VIDEO') { return; } video.pause(); $.off(video, 'volumechange', Volume.change); return video.muted = true; }, rewind(el) { if (el.nodeName === 'VIDEO') { const video = el; if (video.readyState >= video.HAVE_METADATA) { return video.currentTime = 0; } } else if (/\.gif$/.test(el.src)) { const img = el; return $.queueTask(() => img.src = img.src); } }, pushCache(el) { ImageCommon.cache = el; return $.on(el, 'error', ImageCommon.cacheError); }, popCache() { const el = ImageCommon.cache; $.off(el, 'error', ImageCommon.cacheError); delete ImageCommon.cache; return el; }, cacheError() { if (ImageCommon.cache === this) { return delete ImageCommon.cache; } }, decodeError(file, fileObj) { let message; if (file.error?.code !== MediaError.MEDIA_ERR_DECODE) { return false; } if (!(message = $('.warning', fileObj.thumb.parentNode))) { message = $.el('div', { className: 'warning' }); $.after(fileObj.thumb, message); } message.textContent = 'Error: Corrupt or unplayable video'; return true; }, isFromArchive(file) { return (g.SITE.software === 'yotsuba') && !ImageHost.test(file.src.split('/')[2]); }, error(file, post, fileObj, delay, cb) { let timeoutID; const src = fileObj.url.split('/'); let url = null; if ((g.SITE.software === 'yotsuba') && Conf['404 Redirect']) { url = Redirect.to('file', { boardID: post.board.ID, filename: src[src.length - 1] }); } if (!url || !Redirect.securityCheck(url)) { url = null; } if ((post.isDead || fileObj.isDead) && !ImageCommon.isFromArchive(file)) { return cb(url); } if (delay != null) { timeoutID = setTimeout((() => cb(url)), delay); } if (post.isDead || fileObj.isDead) { return; } const redirect = function () { if (!ImageCommon.isFromArchive(file)) { if (delay != null) { clearTimeout(timeoutID); } return cb(url); } }; const threadJSON = g.SITE.urls.threadJSON?.(post); if (!threadJSON) { return; } var parseJSON = function (isArchiveURL) { let needle, postObj; if (this.status === 404) { let archivedThreadJSON; if (!isArchiveURL && (archivedThreadJSON = g.SITE.urls.archivedThreadJSON?.(post))) { $.ajax(archivedThreadJSON, { onloadend() { return parseJSON.call(this, true); } }); } else { post.kill(!post.isClone, fileObj.index); } } if (this.status !== 200) { return redirect(); } for (postObj of this.response.posts) { if (postObj.no === post.ID) { break; } } if (postObj.no !== post.ID) { post.kill(); return redirect(); } else if ((needle = fileObj.docIndex, g.SITE.Build.parseJSON(postObj, post.board).filesDeleted.includes(needle))) { post.kill(true); return redirect(); } else { return url = fileObj.url; } }; return $.ajax(threadJSON, { onloadend() { return parseJSON.call(this, undefined); } }); }, onControls(e) { const target = e.target; return (Conf['Show Controls'] && Conf['Click Passthrough'] && (target.nodeName === 'VIDEO')) || (target.controls && ((target.getBoundingClientRect().bottom - e.clientY) < 35)); }, download(e) { if (this.protocol === 'blob:') { return true; } e.preventDefault(); const { href, download } = this; return CrossOrigin.file(href, function (blob) { if (blob) { const a = $.el('a', { href: URL.createObjectURL(blob), download, hidden: true }); $.add(d.body, a); a.click(); return $.rm(a); } else { return new Notice('warning', `Could not download ${href}`, 20); } }); } }; const Audio = { setupSync(video, audio) { audio.addEventListener('playing', () => { video.currentTime = audio.currentTime % video.duration; video.play(); }); audio.addEventListener('play', () => { video.currentTime = audio.currentTime % video.duration; video.play(); }); audio.addEventListener('pause', () => { video.pause(); }); audio.addEventListener('seeked', () => { video.currentTime = audio.currentTime % video.duration; }); audio.addEventListener('ratechange', () => { video.currentTime = audio.currentTime; video.playbackRate = audio.playbackRate; }); audio.addEventListener('waiting', () => { video.currentTime = audio.currentTime % video.duration; video.pause(); }); audio.addEventListener('canplay', () => { if (audio.currentTime < .1) video.currentTime = 0; }, { once: true }); }, }; var ImageExpand = { enabled: false, on: false, EAI: null, videoControls: null, init() { if (!(this.enabled = Conf['Image Expansion'] && (g.VIEW === 'index' || g.VIEW === 'thread'))) { return; } this.EAI = $.el('a', { className: 'expand-all-shortcut', title: 'Expand All Images', href: 'javascript:;' }); Icon.set(this.EAI, 'expand', 'Expand All Images'); $.on(this.EAI, 'click', this.cb.toggleAll); Header.addShortcut('expand-all', this.EAI, 520); $.on(d, 'scroll visibilitychange', this.cb.playVideos); this.videoControls = $.el('span', { className: 'video-controls' }); $.extend(this.videoControls, { innerHTML: " contract" }); if ($.engine === 'gecko') { $.addStyle('.video-click-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 40px; z-index: 2; cursor: pointer; }', 'xt-video-click-overlay'); } return Callbacks.Post.push({ name: 'Image Expansion', cb: this.node }); }, node() { if (!this.file || (!this.file.isImage && !this.file.isVideo)) { return; } this.file.thumbLink.addEventListener('click', ImageExpand.cb.toggle, true); if (this.isClone) { if (this.file.isExpanding) { ImageExpand.contract(this); return ImageExpand.expand(this); } else if (this.file.isExpanded && this.file.isVideo) { Volume.setup(this.file.fullImage); ImageExpand.setupVideoCB(this); return ImageExpand.setupVideo(this, !this.origin.file.fullImage?.paused || this.origin.file.wasPlaying, this.file.fullImage.controls); } } else if (ImageExpand.on && !this.isHidden && !this.isFetchedQuote && (Conf['Expand spoilers'] || !this.file.isSpoiler) && (Conf['Expand videos'] || !this.file.isVideo)) { return ImageExpand.expand(this); } }, cb: { toggle(e) { const post = Get.postFromNode(this); if (!post?.file) { return; } if (e.target.closest?.('.xt-video-contract')) { e.preventDefault(); e.stopPropagation(); return ImageExpand.toggle(post); } if (e.shiftKey && Conf['MD5 Quick Filter in Threads']) { Filter.quickFilterMD5.call(post); e.preventDefault(); e.stopImmediatePropagation(); return; } if ($.modifiedClick(e)) { return; } const { file } = post; if (file.isExpanded && ImageCommon.onControls(e)) { return; } e.preventDefault(); if (file.isExpanded && file.isVideo) { e.stopPropagation(); $.event('CloseMenu'); } if (!Conf['Autoplay'] && file.fullImage?.paused) { return file.fullImage.play(); } else { return ImageExpand.toggle(post); } }, toggleAll() { let func; $.event('CloseMenu'); const threadRoot = Nav.getThread(); const toggle = function (post) { const { file } = post; if (!file || (!file.isImage && !file.isVideo) || !doc.contains(post.nodes.root)) { return; } if (ImageExpand.on && ((!Conf['Expand spoilers'] && file.isSpoiler) || (!Conf['Expand videos'] && file.isVideo) || (Conf['Expand from here'] && (Header.getTopOf(file.thumb) < 0)) || (Conf['Expand thread only'] && (g.VIEW === 'index') && !threadRoot?.contains(file.thumb)))) { return; } return $.queueTask(func, post); }; if (ImageExpand.on = $.hasClass(ImageExpand.EAI, 'expand-all-shortcut')) { ImageExpand.EAI.className = 'contract-all-shortcut'; ImageExpand.EAI.title = 'Contract All Images'; Icon.set(ImageExpand.EAI, 'shrink', 'Contract All Images'); func = ImageExpand.expand; } else { ImageExpand.EAI.className = 'expand-all-shortcut'; ImageExpand.EAI.title = 'Expand All Images'; Icon.set(ImageExpand.EAI, 'expand', 'Expand All Images'); func = ImageExpand.contract; } return g.posts.forEach(function (post) { for (post of [post, ...post.clones]) { toggle(post); } }); }, playVideos() { return g.posts.forEach(function (post) { for (post of [post, ...post.clones]) { var { file } = post; if (!file || !file.isVideo || !file.isExpanded) { continue; } var video = file.fullImage; var visible = ($.hasAudio(video) && !video.muted) || Header.isNodeVisible(video); if (visible && file.wasPlaying) { delete file.wasPlaying; video.play(); } else if (!visible && !video.paused) { file.wasPlaying = true; video.pause(); } } }); }, setFitness() { return $[this.checked ? 'addClass' : 'rmClass'](doc, this.name.toLowerCase().replace(/\s+/g, '-')); } }, toggle(post) { if (!post.file.isExpanding && !post.file.isExpanded) { post.file.scrollIntoView = Conf['Scroll into view']; ImageExpand.expand(post); return; } ImageExpand.contract(post); if (Conf['Advance on contract']) { let next = post.nodes.root; while ((next = $.x("following::div[contains(@class,'postContainer')][1]", next))) { if (!$('.stub', next) && (next.offsetHeight !== 0)) { break; } } if (next) { return Header.scrollTo(next); } } }, contract(post) { let bottom, el, oldHeight, scrollY; const { file } = post; if (el = file.fullImage) { const top = Header.getTopOf(el); bottom = top + el.getBoundingClientRect().height; oldHeight = d.body.clientHeight; ({ scrollY } = window); } $.rmClass(post.nodes.root, 'expanded-image'); $.rmClass(file.thumb, 'expanding'); $.rm(file.videoControls); if (file.videoOverlay) { $.rm(file.videoOverlay); } if (file.contractButton) { $.rm(file.contractButton); } file.thumbLink.href = file.url; file.thumbLink.target = '_blank'; for (var x of ['isExpanding', 'isExpanded', 'videoControls', 'videoOverlay', 'wasPlaying', 'scrollIntoView', 'contractButton']) { delete file[x]; } if (!el) { return; } if (doc.contains(el)) { if (bottom <= 0) { window.scrollBy(0, ((scrollY - window.scrollY) + d.body.clientHeight) - oldHeight); } else { Header.scrollToIfNeeded(post.nodes.root); } if (window.scrollX > 0) { window.scrollBy(-window.scrollX, 0); } } $.off(el, 'error', ImageExpand.error); ImageCommon.pushCache(el); if (file.isVideo) { ImageCommon.pause(el); for (var eventName in ImageExpand.videoCB) { var cb = ImageExpand.videoCB[eventName]; $.off(el, eventName, cb); } } if (Conf['Restart when Opened']) { ImageCommon.rewind(file.thumb); } delete file.fullImage; $.queueTask(function () { if (file.isExpanding || file.isExpanded) { return; } $.rmClass(el, 'full-image'); if (el.id) { return; } return $.rm(el); }); if (file.audio) { file.audio.remove(); delete file.audio; if (file.audioSlider) { file.audioSlider.remove(); delete file.audioSlider; } } }, expand(post, src) { const { file } = post; const { thumb, thumbLink, isVideo } = file; if (post.isHidden || file.isExpanding || file.isExpanded) { return; } let el; $.addClass(thumb, 'expanding'); file.isExpanding = true; if (file.fullImage) { el = file.fullImage; } else if (ImageCommon.cache?.dataset.fileID === `${post.fullID}.${file.index}`) { el = (file.fullImage = ImageCommon.popCache()); $.on(el, 'error', ImageExpand.error); if (Conf['Restart when Opened'] && (el.id !== 'ihover')) { ImageCommon.rewind(el); } el.removeAttribute('id'); } else { el = (file.fullImage = $.el((isVideo ? 'video' : 'img'))); el.dataset.fileID = `${post.fullID}.${file.index}`; $.on(el, 'error', ImageExpand.error); el.src = src || file.url; } el.className = 'full-image'; $.after(thumb, el); if (isVideo) { if (!file.videoControls) { file.videoControls = ImageExpand.videoControls.cloneNode(true); $.add(file.text, file.videoControls); } if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { if (!file.contractButton) { const btn = $.el('a', { className: 'xt-video-contract', href: 'javascript:;', title: 'Contract' }); Icon.set(btn, 'xmark'); file.contractButton = btn; } $.after(el, file.contractButton); } thumbLink.removeAttribute('href'); thumbLink.removeAttribute('target'); el.loop = true; Volume.setup(el); ImageExpand.setupVideoCB(post); } if (!isVideo) { $.asap((() => el.naturalHeight), () => ImageExpand.completeExpand(post)); } else if (el.readyState >= el.HAVE_METADATA) { ImageExpand.completeExpand(post); } else { $.on(el, 'loadedmetadata', () => ImageExpand.completeExpand(post)); } if (Conf['Enable sound posts'] && Conf['Allow Sound']) { const soundUrlMatch = file.name.match(/\[sound=([^\]]+)]/i); if (soundUrlMatch) { let src = decodeURIComponent(soundUrlMatch[1]); if (!src.startsWith('http')) src = `https://${src}`; const audioEl = $.el('audio', { src }); Volume.setup(audioEl); if (isVideo) { Audio.setupSync(el, audioEl); el.controls = false; } audioEl.loop = true; audioEl.controls = Conf['Show Controls']; audioEl.autoplay = Conf['Autoplay']; $.after(el, audioEl); file.audio = audioEl; } } }, completeExpand(post) { const { file } = post; if (!file.isExpanding) { return; } const bottom = Header.getTopOf(file.thumb) + file.thumb.getBoundingClientRect().height; const oldHeight = d.body.clientHeight; const { scrollY } = window; $.addClass(post.nodes.root, 'expanded-image'); $.rmClass(file.thumb, 'expanding'); file.isExpanded = true; delete file.isExpanding; if (doc.contains(post.nodes.root) && (bottom <= 0)) { window.scrollBy(0, ((scrollY - window.scrollY) + d.body.clientHeight) - oldHeight); } if (file.scrollIntoView) { delete file.scrollIntoView; const imageBottom = Math.min(doc.clientHeight - file.fullImage.getBoundingClientRect().bottom - 25, Header.getBottomOf(file.fullImage)); if (imageBottom < 0) { window.scrollBy(0, Math.min(-imageBottom, Header.getTopOf(file.fullImage))); } } if (file.isVideo) { return ImageExpand.setupVideo(post, Conf['Autoplay'], Conf['Show Controls']); } }, setupVideo(post, playing, controls) { const { audio } = post.file; const fullImage = post.file.fullImage; if (!playing && !audio) { fullImage.controls = controls; ImageExpand.setupOverlay(post); return; } fullImage.controls = false; $.asap((() => doc.contains(fullImage)), function () { if (!d.hidden && Header.isNodeVisible(fullImage)) { fullImage.play(); } else { post.file.wasPlaying = true; } }); fullImage.controls = controls && !audio; ImageExpand.setupOverlay(post); }, setupOverlay(post) { if ($.engine !== 'gecko') { return; } const { file } = post; const video = file.fullImage; let overlay = file.videoOverlay || (file.thumbLink && $('.video-click-overlay', file.thumbLink)); if (video?.controls && !Conf['Click Passthrough']) { if (!overlay) { overlay = $.el('span', { className: 'video-click-overlay' }); $.after(video, overlay); } $.on(overlay, 'mousedown', ImageExpand.overlayPassthrough); file.videoOverlay = overlay; } else if (overlay) { $.rm(overlay); delete file.videoOverlay; } }, overlayPassthrough(e) { if (e.button === 0) { return; } this.style.pointerEvents = 'none'; setTimeout(() => { this.style.pointerEvents = ''; }, 500); }, videoCB: (function () { let mousedown = false; return { mouseover() { return mousedown = false; }, mousedown(e) { if (e.button === 0) { return mousedown = true; } }, mouseup(e) { if (e.button === 0) { return mousedown = false; } }, mouseout(e) { if (((e.buttons & 1) || mousedown) && (e.clientX <= this.getBoundingClientRect().left)) { return ImageExpand.toggle(Get.postFromNode(this)); } } }; })(), setupVideoCB(post) { for (var eventName in ImageExpand.videoCB) { var cb = ImageExpand.videoCB[eventName]; $.on(post.file.fullImage, eventName, cb); } if (post.file.videoControls) { return $.on(post.file.videoControls.firstElementChild, 'click', () => ImageExpand.toggle(post)); } }, error() { const post = Get.postFromNode(this); $.rm(this); if (!post?.file) { return; } delete post.file.fullImage; if (!post.file.isExpanding && !post.file.isExpanded) { return; } if (ImageCommon.decodeError(this, post.file)) { return ImageExpand.contract(post); } if (ImageCommon.isFromArchive(this)) { return ImageExpand.contract(post); } return ImageCommon.error(this, post, post.file, 10 * SECOND, function (URL) { if (post.file.isExpanding || post.file.isExpanded) { ImageExpand.contract(post); if (URL) { return ImageExpand.expand(post, URL); } } }); }, menu: { init() { if (!ImageExpand.enabled) { return; } const el = $.el('span', { textContent: 'Image Expansion', className: 'image-expansion-link' }); const { createSubEntry } = ImageExpand.menu; const subEntries = []; for (var name in Config.imageExpansion) { var conf = Config.imageExpansion[name]; subEntries.push(createSubEntry(name, conf[1])); } return Header.menu.addEntry({ el, order: 105, subEntries }); }, createSubEntry(name, desc) { const label = UI.checkbox(name, name); label.title = desc; const input = label.firstElementChild; if (['Fit width', 'Fit height'].includes(name)) { $.on(input, 'change', ImageExpand.cb.setFitness); } $.event('change', null, input); $.on(input, 'change', $.cb.checked); return { el: label }; } } }; class Post { toString() { return this.ID; } constructor(root, thread, board, flags = {}) { if (root === undefined && thread === undefined && board === undefined) return; const postRoot = root; this.root = postRoot; this.thread = thread; this.board = board; $.extend(this, flags); this.ID = +postRoot.id.match(/\d*$/)[0]; this.postID = this.ID; this.threadID = this.thread.ID; this.boardID = this.board.ID; this.siteID = g.SITE.ID; this.fullID = `${this.board}.${this.ID}`; this.context = this; this.isReply = (this.ID !== this.threadID); postRoot.dataset.fullID = this.fullID; this.nodes = this.parseNodes(postRoot); if (!this.isReply) { this.thread.OP = this; for (var key of ['isSticky', 'isClosed', 'isArchived']) { var selector; if (selector = g.SITE.selectors.icons[key]) { this.thread[key] = !!$(selector, this.nodes.info); } } if (this.thread.isArchived) { this.thread.isClosed = true; this.thread.kill(); } } const name = this.nodes.name?.textContent; const tripcode = this.nodes.tripcode?.textContent; this.info = { subject: this.nodes.subject?.textContent || undefined, name, email: this.nodes.email ? decodeURIComponent(this.nodes.email.href.replace(/^mailto:/, '')) : undefined, tripcode, uniqueID: this.nodes.uniqueID?.textContent, capcode: this.nodes.capcode?.textContent.replace('## ', ''), pass: this.nodes.pass?.title.match(/\d*$/)[0], flagCode: this.nodes.flag?.className.match(/flag-(\w+)/)?.[1].toUpperCase(), flagCodeTroll: this.nodes.flag?.className.match(/bfl-(\w+)/)?.[1].toUpperCase(), flag: this.nodes.flag?.title, date: this.nodes.date ? g.SITE.parseDate(this.nodes.date) : undefined, nameBlock: Conf['Anonymize'] ? 'Anonymous' : `${name || ''} ${tripcode || ''}`.trim(), }; if (this.info.capcode) { this.info.nameBlock += ` ## ${this.info.capcode}`; } if (this.info.uniqueID) { this.info.nameBlock += ` (ID: ${this.info.uniqueID})`; } this.parseComment(); this.parseQuotes(); this.parseFiles(); this.isDead = false; this.isHidden = false; this.clones = []; if (g.posts.get(this.fullID)) { this.isRebuilt = true; this.clones = g.posts.get(this.fullID).clones; for (var clone of this.clones) { clone.origin = this; } } if (!this.isFetchedQuote && (this.ID > this.thread.lastPost)) { this.thread.lastPost = this.ID; } if (this.ID < this.thread.lastPost && g.VIEW === 'thread') { this.board.posts.insert(this.ID, this); this.thread.posts.insert(this.ID, this); g.posts.insert(this.fullID, this, key => +(key.split('.')[1]) < this.ID); } else { this.board.posts.push(this.ID, this); this.thread.posts.push(this.ID, this); g.posts.push(this.fullID, this); } } parseNodes(root) { const s = g.SITE.selectors; const post = $(s.post, root) || root; const info = $(s.infoRoot, post); const nodes = { root, bottom: this.isReply || !g.SITE.isOPContainerThread ? root : $(s.opBottom, root), post, info, comment: $(s.comment, post), quotelinks: [], archivelinks: [], embedlinks: [], backlinks: post.getElementsByClassName('backlink'), uniqueIDRoot: undefined, uniqueID: undefined, }; for (var key in s.info) { var selector = s.info[key]; nodes[key] = $(selector, info); } g.SITE.parseNodes?.(this, nodes); if (!nodes.uniqueIDRoot) { nodes.uniqueIDRoot = nodes.uniqueID; } return nodes; } parseComment() { let bq; this.nodes.comment.normalize(); this.nodes.commentClean = (bq = this.nodes.comment.cloneNode(true)); g.SITE.cleanComment?.(bq); return this.info.comment = this.nodesToText(bq); } commentDisplay() { const bq = this.nodes.commentClean.cloneNode(true); if (!Conf['Remove Spoilers'] && !Conf['Reveal Spoilers']) { this.cleanSpoilers(bq); } g.SITE.cleanCommentDisplay?.(bq); return this.nodesToText(bq).trim().replace(/\s+$/gm, ''); } commentOrig() { const bq = this.nodes.commentClean.cloneNode(true); g.SITE.insertTags?.(bq); return this.nodesToText(bq); } nodesToText(bq) { let node; let text = ""; const nodes = $.X('.//br|.//text()', bq); let i = 0; while ((node = nodes.snapshotItem(i++))) { text += node.data || '\n'; } return text; } cleanSpoilers(bq) { const spoilers = $$(g.SITE.selectors.spoiler, bq); for (var node of spoilers) { $.replace(node, $.tn('[spoiler]')); } } parseQuotes() { this.quotes = []; for (var quotelink of $$(g.SITE.selectors.quotelink, this.nodes.comment)) { this.parseQuote(quotelink); } } parseQuote(quotelink) { const match = quotelink.href.match(g.SITE.regexp.quotelink); if (!match && (!this.isClone || !quotelink.dataset.postID)) { return; } this.nodes.quotelinks.push(quotelink); if (this.isClone) { return; } const fullID = `${match[1]}.${match[3]}`; if (!this.quotes.includes(fullID)) this.quotes.push(fullID); } parseFiles() { let file; this.files = []; const fileRoots = this.fileRoots(); let index = 0; for (let docIndex = 0; docIndex < fileRoots.length; docIndex++) { var fileRoot = fileRoots[docIndex]; if (file = this.parseFile(fileRoot)) { file.index = (index++); file.docIndex = docIndex; this.files.push(file); } } if (this.files.length) { return this.file = this.files[0]; } } fileRoots() { if (g.SITE.selectors.multifile) { const roots = $$(g.SITE.selectors.multifile, this.nodes.root); if (roots.length) { return roots; } } return [this.nodes.root]; } parseFile(fileRoot) { const file = { isDead: false }; for (var key in g.SITE.selectors.file) { var selector = g.SITE.selectors.file[key]; file[key] = $(selector, fileRoot); } file.thumbLink = file.thumb?.parentNode; if (!(file.text && file.link)) { return; } if (!g.SITE.parseFile(this, file)) { return; } $.extend(file, { url: file.link.href, isImage: $.isImage(file.link.href), isVideo: $.isVideo(file.link.href) }); const fileSize = file.size; let size = +fileSize.match(/[\d.]+/)[0]; let unit = ['B', 'KB', 'MB', 'GB'].indexOf(fileSize.match(/\w+$/)[0]); while (unit-- > 0) { size *= 1024; } file.sizeInBytes = size; return file; } kill(file = false, index = 0) { let strong; if (file) { if (this.isDead || this.files[index].isDead) { return; } this.files[index].isDead = true; $.addClass(this.nodes.root, 'deleted-file'); } else { if (this.isDead) { return; } this.isDead = true; $.rmClass(this.nodes.root, 'deleted-file'); $.addClass(this.nodes.root, 'deleted-post'); } if (!(strong = $('strong.warning', this.nodes.info))) { strong = $.el('strong', { className: 'warning' }); $.after($('input', this.nodes.info), strong); } strong.textContent = file ? '[File deleted]' : '[Deleted]'; if (this.isClone) { return; } for (var clone of this.clones) { clone.kill(file, index); } if (file) { return; } for (var quotelink of Get.allQuotelinksLinkingTo(this)) { if (!$.hasClass(quotelink, 'deadlink')) { $.add(quotelink, Post.deadMark.cloneNode(true)); $.addClass(quotelink, 'deadlink'); } } } markAsFromArchive() { let strong = $('strong.warning', this.nodes.info); if (!strong) { strong = $.el('strong', { className: 'warning' }); $.after($('input', this.nodes.info), strong); } strong.textContent = '[Deleted, restored from external archive]'; $.addClass(this.nodes.root, 'from-archive'); if (this.isClone) { return; } for (var clone of this.clones) { clone.markAsFromArchive(); } for (var quotelink of Get.allQuotelinksLinkingTo(this)) { $.addClass(quotelink, 'from-archive-link'); } } resurrect() { this.isDead = false; $.rmClass(this.nodes.root, 'deleted-post', 'from-archive'); const strong = $('strong.warning', this.nodes.info); if (this.files.some(file => file.isDead)) { $.addClass(this.nodes.root, 'deleted-file'); strong.textContent = '[File deleted]'; } else { $.rm(strong); } if (this.isClone) { return; } for (var clone of this.clones) { clone.resurrect(); } for (var quotelink of Get.allQuotelinksLinkingTo(this)) { if ($.hasClass(quotelink, 'deadlink')) { $.rm($('.qmark-dead', quotelink)); } $.rmClass(quotelink, 'deadlink', 'from-archive-link'); } } collect() { g.posts.rm(this.fullID); this.thread.posts.rm(this); this.board.posts.rm(this); } addClone(context, contractThumb) { Callbacks.Post.execute(this); return new PostClone(this, context, contractThumb); } rmClone(index) { this.clones.splice(index, 1); for (var clone of this.clones.slice(index)) { clone.nodes.root.dataset.clone = String(index++); } } setCatalogOP(isCatalogOP) { this.nodes.root.classList.toggle('catalog-container', isCatalogOP); this.nodes.root.classList.toggle('opContainer', !isCatalogOP); this.nodes.post.classList.toggle('catalog-post', isCatalogOP); this.nodes.post.classList.toggle('op', !isCatalogOP); this.nodes.post.style.left = (this.nodes.post.style.right = ''); } } Post.deadMark = (() => { const el = document.createElement('span'); el.textContent = '\u00A0(Dead)'; el.className = 'qmark-dead'; return el; })(); class PostClone extends Post { constructor(origin, context, contractThumb) { super(); this.isClone = true; let file, fileRoots = [], key; this.origin = origin; this.context = context; for (key of ['ID', 'postID', 'threadID', 'boardID', 'siteID', 'fullID', 'board', 'thread', 'info', 'quotes', 'isReply']) { this[key] = this.origin[key]; } const { nodes } = this.origin; const root = contractThumb ? this.cloneWithoutVideo(nodes.root) : nodes.root.cloneNode(true); for (var node of [root, ...$$('[id]', root)]) { node.id += `_${PostClone.suffix}`; } PostClone.suffix++; for (var inline of $$('.inline', root)) { $.rm(inline); } for (var inlined of $$('.inlined', root)) { $.rmClass(inlined, 'inlined'); } this.nodes = this.parseNodes(root); root.hidden = false; $.rmClass(root, 'forwarded'); $.rmClass(this.nodes.post, 'highlight'); if (!this.isReply) { this.setCatalogOP(false); $.rm($('.catalog-link', this.nodes.post)); $.rm($('.catalog-stats', this.nodes.post)); $.rm($('.catalog-replies', this.nodes.post)); } this.parseQuotes(); this.quotes = [...this.origin.quotes]; this.files = []; if (this.origin.files.length) { fileRoots = this.fileRoots(); } for (var originFile of this.origin.files) { file = { ...originFile }; var fileRoot = fileRoots[file.docIndex]; for (key in g.SITE.selectors.file) { var selector = g.SITE.selectors.file[key]; file[key] = $(selector, fileRoot); } file.thumbLink = file.thumb?.parentNode; if (file.thumbLink) { file.fullImage = $('.full-image', file.thumbLink); } file.videoOverlay = (file.thumbLink && $('.video-click-overlay', file.thumbLink)) || undefined; file.videoControls = $('.video-controls', file.text); if (file.videoThumb) { file.thumb.muted = true; } this.files.push(file); } if (this.files.length) { this.file = this.files[0]; if (this.file.thumb && contractThumb) { ImageExpand.contract(this); } } if (this.origin.isDead) { this.isDead = true; } root.dataset.clone = this.origin.clones.push(this) - 1; return this; } cloneWithoutVideo(node) { if ((node.tagName === 'VIDEO') && !node.dataset.md5) { return []; } else if ((node.nodeType === Node.ELEMENT_NODE) && $('video', node)) { const clone = node.cloneNode(false); for (var child of node.childNodes) { $.add(clone, this.cloneWithoutVideo(child)); } return clone; } else { return node.cloneNode(true); } } } PostClone.suffix = 0; var ExpandComment = { init() { if ((g.VIEW !== 'index') || !Conf['Comment Expansion'] || Conf['JSON Index']) { return; } return Callbacks.Post.push({ name: 'Comment Expansion', cb: this.node }); }, node() { let a; if (a = $('.abbr > a:not([onclick])', this.nodes.comment)) { return $.on(a, 'click', ExpandComment.cb); } }, callbacks: [], cb(e) { e.preventDefault(); return ExpandComment.expand(Get.postFromNode(this)); }, expand(post) { let a; if (!post) { return; } if (post.nodes.longComment && !post.nodes.longComment.parentNode) { $.replace(post.nodes.shortComment, post.nodes.longComment); post.nodes.comment = post.nodes.longComment; return; } if (!(a = $('.abbr > a', post.nodes.comment))) { return; } a.textContent = `Post No.${post} Loading...`; return $.cache(g.SITE.urls.threadJSON({ boardID: post.boardID, threadID: post.threadID }), function () { return ExpandComment.parse(this, a, post); }); }, contract(post) { if (!post.nodes.shortComment) { return; } const a = $('.abbr > a', post.nodes.shortComment); a.textContent = 'here'; $.replace(post.nodes.longComment, post.nodes.shortComment); return post.nodes.comment = post.nodes.shortComment; }, parse(req, a, post) { let postObj, spoilerRange; const { status } = req; if (![200, 304].includes(status)) { a.textContent = status ? `Error ${req.statusText} (${status})` : 'Connection Error'; return; } const { posts } = req.response; if (spoilerRange = posts[0].custom_spoiler) { g.SITE.Build.spoilerRange[g.BOARD] = spoilerRange; } for (postObj of posts) { if (postObj.no === post.ID) { break; } } if (postObj.no !== post.ID) { a.textContent = `Post No.${post} not found.`; return; } const { comment } = post.nodes; const clone = comment.cloneNode(false); clone.innerHTML = postObj.com; for (var quote of $$('.quotelink', clone)) { var href = quote.getAttribute('href'); if (href[0] === '/') { continue; } if (href[0] === '#') { quote.href = `${a.pathname.split(/\/+/).splice(0, 4).join('/')}${href}`; } else { quote.href = `${a.pathname.split(/\/+/).splice(0, 3).join('/')}/${href}`; } } post.nodes.shortComment = comment; $.replace(comment, clone); post.nodes.comment = (post.nodes.longComment = clone); post.parseComment(); post.parseQuotes(); for (var callback of ExpandComment.callbacks) { callback.call(post); } } }; var EmbeddingPage = `
`; var Time = { init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive') || !Conf['Time Formatting']) { return; } Callbacks.Post.push({ name: 'Time Formatting', cb: this.node }); }, node() { if (!this.info.date || this.isClone) { return; } const { textContent } = this.nodes.date; this.nodes.date.textContent = textContent.match(/^\s*/)[0] + Time.format(this.info.date) + textContent.match(/\s*$/)[0]; }, format(date, formatString = Conf['time']) { return formatString.replace(/%(.)/g, function (s, c) { if ($.hasOwn(Time.formatters, c)) { return Time.formatters[c].call(date); } else { return s; } }); }, zeroPad(n) { if (n < 10) { return `0${n}`; } else { return n; } }, formatterCache: new Map(), formatters: { a() { let formatter = Time.formatterCache.get('a'); if (!formatter) { formatter = Intl.DateTimeFormat(Conf['timeLocale'] || undefined, { weekday: 'short' }); Time.formatterCache.set('a', formatter); } return formatter.format(this); }, A() { let formatter = Time.formatterCache.get('A'); if (!formatter) { formatter = Intl.DateTimeFormat(Conf['timeLocale'] || undefined, { weekday: 'long' }); Time.formatterCache.set('A', formatter); } return formatter.format(this); }, b() { let formatter = Time.formatterCache.get('b'); if (!formatter) { formatter = Intl.DateTimeFormat(Conf['timeLocale'] || undefined, { month: 'short' }); Time.formatterCache.set('b', formatter); } return formatter.format(this); }, B() { let formatter = Time.formatterCache.get('B'); if (!formatter) { formatter = Intl.DateTimeFormat(Conf['timeLocale'] || undefined, { month: 'long' }); Time.formatterCache.set('B', formatter); } return formatter.format(this); }, d() { return Time.zeroPad(this.getDate()); }, e() { return this.getDate(); }, H() { return Time.zeroPad(this.getHours()); }, I() { return Time.zeroPad((this.getHours() % 12) || 12); }, k() { return this.getHours(); }, l() { return (this.getHours() % 12) || 12; }, m() { return Time.zeroPad(this.getMonth() + 1); }, n() { return this.getMonth() + 1; }, M() { return Time.zeroPad(this.getMinutes()); }, p() { let formatter = Time.formatterCache.get('p'); if (!formatter) { formatter = Intl.DateTimeFormat(Conf['timeLocale'] || undefined, { hour: 'numeric', hour12: true }); Time.formatterCache.set('p', formatter); } const parts = formatter.formatToParts(this); return parts.find((entry) => entry.type === 'dayPeriod')?.value || ''; }, P() { return Time.formatters.p.call(this).toLowerCase(); }, S() { return Time.zeroPad(this.getSeconds()); }, y() { return this.getFullYear().toString().slice(2); }, Y() { return this.getFullYear(); }, '%'() { return '%'; } }, }; function EmbedFxTwitter(a) { const el = $.el('div', { innerHTML: '' }); const shouldTranslate = Conf.fxtLang ? `/${Conf.fxtLang}` : ''; const maxReplies = +Conf.fxtMaxReplies; CrossOrigin.cachePromise(`${Conf.fxtUrl}/${a.dataset.uid}${shouldTranslate}`).then(async (req) => { if (req.status === 404) { el.textContent = '404: tweet not found'; return; } const { tweet } = req.response; function renderMedia(tweet) { return tweet.media?.all?.map((media) => { switch (media.type) { case 'photo': return h("div", { class: "fxt-media" }, h("a", { href: media.url, target: "_blank", referrerpolicy: "no-referrer" }, h("img", { src: media.url, alt: media.altText, width: media.width, height: media.height, referrerpolicy: "no-referrer" }))); case 'video': case 'gif': return h("div", { class: "fxt-media" }, h("video", { controls: true, width: media.width, height: media.height, poster: media.thumbnail_url, preload: "meta" }, h("source", { src: media.url, type: media.format }))); default: console.warn(`FxTwitter media type ${media.type} not recognized`); } }) || []; } function renderDate(tweet) { return Time.format(new Date(tweet.created_at)); } function renderPoll(tweet) { let maxPercentage = 0; let maxChoiceIndex = -1; tweet.poll.choices.forEach((choice, index) => { if (choice.percentage > maxPercentage) { maxPercentage = choice.percentage; maxChoiceIndex = index; } }); return h("div", { class: "fxt-poll" }, ...tweet.poll.choices.map((choice, index) => h("div", { class: `fxt-choice ${index === maxChoiceIndex ? 'highlight' : ''}` }, h("span", { class: "fxt-choice_label" }, choice.label), h("span", { class: "fxt-choice_percentage" }, choice.percentage, "%"), h("div", { class: "fxt-bar", style: `width: ${choice.percentage}%` }))), h("div", { class: "total-votes" }, tweet.poll.total_votes.toLocaleString(), " votes")); } function renderTranslation(tweet) { if (!tweet?.translation?.target_lang || tweet?.translation?.source_lang === tweet?.translation?.target_lang) { return ''; } return h(hFragment, null, h("hr", null), h("p", null, "Translated from ", tweet.translation.source_lang_en), h("p", { lang: tweet.translation.target_lang }, ...renderText(tweet.translation.text))); } function renderMeta(tweet) { return h("div", { class: "fxt-meta" }, h("a", { class: "fxt-meta_profile", href: tweet.author.url, title: tweet.author.description, target: "_blank", referrerpolicy: "no-referrer" }, h("div", { class: "fxt-meta_avatar" }, h("img", { src: tweet.author.avatar_url, referrerpolicy: "no-referrer" })), h("div", { class: "fxt-meta_author" }, h("span", { class: "fxt-meta_author_username" }, tweet.author.name), h("span", { class: "fxt-meta_author_account" }, "@", tweet.author.screen_name))), h("a", { href: tweet.url, title: "Open tweet in a new tab", target: "_blank", referrerpolicy: "no-referrer" }, Icon.raw('link'))); } function renderText(inputText) { const result = []; let endLast = 0; for (const match of inputText.matchAll(/(?:@|\#)\w+/g)) { result.push(inputText.slice(endLast, match.index), h("a", { href: `https://x.com/${match[0].startsWith('#') ? 'hashtag/' : ''}${match[0].slice(1)}`, target: "_blank", referrerpolicy: "no-referrer" }, match[0])); endLast = match.index + match[0].length; } result.push(inputText.slice(endLast)); return result; } function renderCommunityNote(note) { const content = []; let i = 0; if (note.entities) { for (const entity of note.entities) { if (entity.ref.url) { if (i < entity.fromIndex) content.push(...renderText(note.text.slice(i, entity.fromIndex))); content.push(h("a", { href: entity.ref.url, target: "_blank", referrerpolicy: "no-referrer" }, note.text.slice(entity.fromIndex, entity.toIndex))); i = entity.toIndex; } } } if (i < note.text.length - 1) content.push(...renderText(note.text.slice(i))); return h("div", { class: "fxt-community_note" }, h("div", { class: "fxt-community_note-header" }, "Community Note"), h("div", { class: "fxt-community_note-text" }, ...content)); } async function renderQuote(quote) { return h("div", { class: "fxt-quote_container" }, await renderTweet(quote, 'quote')); } async function renderReplies(tweet) { const replies = []; let depth = 0; while (tweet.replying_to && tweet.replying_to_status && depth < maxReplies) { const replyUrl = `${Conf.fxtUrl}/${tweet.replying_to}/status/${tweet.replying_to_status}`; try { const replyData = await CrossOrigin.cachePromise(replyUrl); tweet = replyData.response.tweet; const replyHTML = await renderTweet(tweet, 'reply'); replies.unshift(replyHTML); depth++; } catch (error) { console.error(`Error fetching/rendering reply tweet: ${error instanceof Error ? error.message : String(error)}`); console.log(tweet); const url = `${Conf.fxtUrl}/${tweet.replying_to}/status/${tweet.replying_to_status}`; return h("div", { class: "fxt-reply_container" }, h("article", { class: "fxt-card fxt-tweet-reply" }, h("div", { class: "fxt-content warning" }, "Failed trying to load ", h("a", { href: url, target: "_blank", referrerpolicy: "no-referrer" }, url), h("br", null), "This tweet has probably been deleted or removed.", h("br", null), "This also breaks the reply chain, so you may want to view the original tweet."))); } } return h("div", { class: "fxt-reply_container" }, ...replies); } async function renderTweet(tweet, type) { const media = renderMedia(tweet); const quote = (tweet?.quote) ? await renderQuote(tweet.quote) : ''; const poll = (tweet?.poll) ? renderPoll(tweet) : ''; const created_at = renderDate(tweet); const translation = (shouldTranslate) ? renderTranslation(tweet) : ''; const note = tweet.community_note ? renderCommunityNote(tweet.community_note) : ''; return h("article", { class: `fxt-card fxt-tweet-${type}` }, renderMeta(tweet), h("div", { class: "fxt-content" }, h("div", { class: "fxt-text", lang: tweet.lang }, ...renderText(tweet.text), translation), (media.length || poll) && h("div", { class: `fxt-media_container ${tweet.media?.all?.length > 1 ? 'fxt-media-multiple' : ''}` }, poll, ...media), note, quote), h("div", { class: "fxt-stats" }, h("div", { class: "fxt-stats_time" }, created_at), h("div", { class: "fxt-stats_meta" }, h("span", { class: "fxt-likes" }, Icon.raw("comment"), tweet.replies.toLocaleString()), h("span", { class: "fxt-reposts" }, Icon.raw("shuffle"), tweet.retweets.toLocaleString()), h("span", { class: "fxt-replies" }, Icon.raw("heart"), tweet.likes.toLocaleString())))); } async function renderFullTweet(tweet) { const mainTweetHTML = await renderTweet(tweet, 'original'); const repliesHTML = tweet.replying_to ? await renderReplies(tweet) : ''; return h(hFragment, null, repliesHTML, mainTweetHTML); } const rendered = await renderFullTweet(tweet); el.innerHTML = rendered.innerHTML; for (const link of el.querySelectorAll('a')) { Linkify.rewriteXLink(link); } for (const textEl of el.getElementsByClassName('fxt-text')) { Linkify.process(textEl); } el.style.resize = ''; el.classList.add('fxt-card_container'); el.style.height = ''; el.style.width = ''; el.style.overflow = ''; }); return el; } var Embedding = { types: null, dialog: null, media: null, lastEmbed: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive') || !Conf['Linkify'] || (!Conf['Embedding'] && !Conf['Link Title'] && !Conf['Cover Preview'])) { return; } this.types = dict(); for (var type of this.ordered_types) { this.types[type.key] = type; } if (Conf['Embedding'] && (g.VIEW !== 'archive')) { this.dialog = UI.dialog('embedding', { innerHTML: EmbeddingPage }); this.media = $('#media-embed', this.dialog); $.one(d, '4chanXInitFinished', this.ready); $.on(d, 'IndexRefreshInternal', () => g.posts?.forEach(function (post) { for (post of [post, ...post.clones]) { for (var embed of post.nodes.embedlinks) { Embedding.cb.catalogRemove.call(embed); } } })); } if (Embedding.shouldFetchTitles()) { $.on(d, '4chanXInitFinished PostsInserted', function () { for (const service of Object.values(Embedding.types)) { if (service.title?.batchSize) { Embedding.flushTitles(service.title); } } }); } }, events(post) { let el, i, items; if (g.VIEW === 'archive') { return; } if (Conf['Embedding']) { i = 0; items = (post.nodes.embedlinks = $$('.embedder', post.nodes.comment)); while ((el = items[i++])) { $.on(el, 'click', Embedding.cb.click); if ($.hasClass(el, 'embedded')) { Embedding.cb.toggle.call(el); } } } if (Conf['Cover Preview']) { i = 0; items = $$('.linkify', post.nodes.comment); while ((el = items[i++])) { var data; if (data = Embedding.services(el)) { Embedding.preview(data); } } return; } }, process(link, post) { let data; if (!Conf['Embedding'] && !Conf['Link Title'] && !Conf['Cover Preview']) { return; } if ($.x('ancestor::pre', link)) { return; } if (data = Embedding.services(link)) { data.post = post; if (Conf['Embedding'] && (g.VIEW !== 'archive')) { Embedding.embed(data); } if (Embedding.shouldFetchTitles()) Embedding.title(data); if (Conf['Cover Preview'] && (g.VIEW !== 'archive')) { return Embedding.preview(data); } } }, services(link) { const { href } = link; for (var type of Embedding.ordered_types) { var match; if (match = type.regExp.exec(href)) { return { key: type.key, uid: match[1], options: match[2], link }; } } }, embed(data) { const { key, uid, options, link } = data; const post = data.post; const { href } = link; $.addClass(link, key.toLowerCase()); const embed = $.el('a', { className: 'embedder', href: 'javascript:;' }, { innerHTML: '(unembed)' }); const object = { key, uid, options, href }; for (var name in object) { var value = object[name]; embed.dataset[name] = value; } $.on(embed, 'click', Embedding.cb.click); $.after(link, [$.tn(' '), embed]); post.nodes.embedlinks.push(embed); if (Conf['Auto-embed'] && !Conf['Floating Embeds'] && !post.isFetchedQuote) { if ($.hasClass(doc, 'catalog-mode')) { return $.addClass(embed, 'embed-removed'); } else { return Embedding.cb.toggle.call(embed); } } }, ready() { if (!Main.isThisPageLegit()) { return; } $.addClass(Embedding.dialog, 'empty'); const close = $('.close', Embedding.dialog); const jump = $('.jump', Embedding.dialog); $.on(close, 'click', Embedding.closeFloat); $.on($('.move', Embedding.dialog), 'mousedown', Embedding.dragEmbed); $.on(jump, 'click', function () { if (doc.contains(Embedding.lastEmbed)) return Header.scrollTo(Embedding.lastEmbed); }); Icon.set(jump, 'arrowRightLong'); Icon.set(close, 'xmark'); return $.add(d.body, Embedding.dialog); }, closeFloat() { delete Embedding.lastEmbed; $.addClass(Embedding.dialog, 'empty'); return $.replace(Embedding.media.firstChild, $.el('div')); }, dragEmbed() { const { style } = Embedding.media; if (Embedding.dragEmbed.mouseup) { $.off(d, 'mouseup', Embedding.dragEmbed); Embedding.dragEmbed.mouseup = false; style.pointerEvents = ''; return; } $.on(d, 'mouseup', Embedding.dragEmbed); Embedding.dragEmbed.mouseup = true; return style.pointerEvents = 'none'; }, title(data) { let service; const { key, uid, options, link, post } = data; if (!(service = Embedding.types[key].title)) { return; } $.addClass(link, key.toLowerCase()); if (service.batchSize) { (service.queue || (service.queue = [])).push(data); if (service.queue.length >= service.batchSize) { return Embedding.flushTitles(service); } } else { return CrossOrigin.cache(service.api(uid), (function () { return Embedding.cb.title(this, data); })); } }, flushTitles(service) { let data; const { queue } = service; if (!queue?.length) { return; } service.queue = []; const cb = function () { for (data of queue) { Embedding.cb.title(this, data); } }; return CrossOrigin.cache(service.api(queue.map((data) => data.uid)), cb); }, preview(data) { let service; const { key, uid, link } = data; if (!(service = Embedding.types[key].preview)) { return; } return $.on(link, 'mouseover', function (e) { const src = service.url(uid); const { height } = service; const el = $.el('img', { src, id: 'ihover' }); el.setAttribute("referrerpolicy", "no-referrer"); $.add(Header.hover, el); return UI.hover({ root: link, el, latestEvent: e, endEvents: 'mouseout click', height }); }); }, cb: { click(e) { e.preventDefault(); if (!$.hasClass(this, 'embedded') && (Conf['Floating Embeds'] || $.hasClass(doc, 'catalog-mode'))) { let div; if (!(div = Embedding.media.firstChild)) { return; } $.replace(div, Embedding.cb.embed(this)); const post = Get.postFromNode(this); if (!post) { return; } Embedding.lastEmbed = post.nodes.root; return $.rmClass(Embedding.dialog, 'empty'); } else { return Embedding.cb.toggle.call(this); } }, toggle() { if ($.hasClass(this, "embedded")) { $.rm(this.nextElementSibling); } else { $.after(this, Embedding.cb.embed(this)); } return $.toggleClass(this, 'embedded'); }, embed(a) { let el, type; const container = $.el('div', { className: 'media-embed' }); $.add(container, (el = (type = Embedding.types[a.dataset.key]).el(a))); el.style.cssText = (type.style != null) ? type.style : 'border: none; width: 640px; height: 360px;'; return container; }, catalogRemove() { const isCatalog = $.hasClass(doc, 'catalog-mode'); if ((isCatalog && $.hasClass(this, 'embedded')) || (!isCatalog && $.hasClass(this, 'embed-removed'))) { Embedding.cb.toggle.call(this); return $.toggleClass(this, 'embed-removed'); } }, title(req, data) { let text; const { key, uid, options, link } = data; const post = data.post; const service = Embedding.types[key].title; let { status } = req; if ([200, 304].includes(status) && service.status) { status = service.status(req.response)[0]; } if (!status) { return; } text = `[${key}] ${(() => { switch (status) { case 200: case 304: text = service.text(req.response, uid); if (typeof text === 'string') { return text; } else { return text = link.textContent; } case 404: return "Not Found"; case 403: case 401: return "Forbidden or Private"; default: return `${status}'d`; } })()}`; link.dataset.original = link.textContent; link.textContent = text; for (var post2 of post.clones) { for (var link2 of $$('a.linkify', post2.nodes.comment)) { if (link2.href === link.href) { if (link2.dataset.original == null) { link2.dataset.original = link2.textContent; } link2.textContent = text; } } } } }, ordered_types: [{ key: 'audio', regExp: /^[^?#]+\.(?:mp3|m4a|oga|wav|flac)(?:[?#]|$)/i, style: '', el(a) { return $.el('audio', { controls: true, preload: 'auto', src: a.dataset.href }); } }, { key: 'image', regExp: /^[^?#]+\.(?:gif|png|jpg|jpeg|bmp|webp)(?::\w+)?(?:[?#]|$)/i, style: '', el(a) { const hrefEsc = E(a.dataset.href); return $.el('div', { innerHTML: `` }); } }, { key: 'video', regExp: /^[^?#]+\.(?:og[gv]|webm|mp4)(?:[?#]|$)/i, style: 'max-width: 80vw; max-height: 80vh;', el(a) { const el = $.el('video', { hidden: true, controls: true, preload: 'auto', src: a.dataset.href, loop: ImageHost.test(a.dataset.href.split('/')[2]) }); $.on(el, 'loadedmetadata', function () { if ((el.videoHeight === 0) && el.parentNode) { return $.replace(el, Embedding.types.audio.el(a)); } else { return el.hidden = false; } }); return el; } }, { key: 'PeerTube', regExp: /^(\w+:\/\/[^\/]+\/videos\/watch\/\w{8}-\w{4}-\w{4}-\w{4}-\w{12})(.*)/, el(a) { let start; const options = (start = a.dataset.options.match(/[?&](start=\w+)/)) ? `?${start[1]}` : ''; const el = $.el('iframe', { src: a.dataset.uid.replace('/videos/watch/', '/videos/embed/') + options }); el.setAttribute("allowfullscreen", "true"); return el; } }, { key: 'BitChute', regExp: /^\w+:\/\/(?:www\.)?bitchute\.com\/video\/([\w\-]+)/, el(a) { const el = $.el('iframe', { src: `https://www.bitchute.com/embed/${a.dataset.uid}/` }); el.setAttribute("allowfullscreen", "true"); return el; } }, { key: 'Clyp', regExp: /^\w+:\/\/(?:www\.)?clyp\.it\/(\w{8})/, style: 'border: 0; width: 640px; height: 160px;', el(a) { return $.el('iframe', { src: `https://clyp.it/${a.dataset.uid}/widget` }); }, title: { api(uid) { return `https://api.clyp.it/oembed?url=https://clyp.it/${uid}`; }, text(_) { return _.title; } } }, { key: 'Dailymotion', regExp: /^\w+:\/\/(?:(?:www\.)?dailymotion\.com\/(?:embed\/)?video|dai\.ly)\/([A-Za-z0-9]+)[^?]*(.*)/, el(a) { let start; const options = (start = a.dataset.options.match(/[?&](start=\d+)/)) ? `?${start[1]}` : ''; const el = $.el('iframe', { src: `//www.dailymotion.com/embed/video/${a.dataset.uid}${options}` }); el.setAttribute("allowfullscreen", "true"); return el; }, title: { api(uid) { return `https://api.dailymotion.com/video/${uid}`; }, text(_) { return _.title; } }, preview: { url(uid) { return `https://www.dailymotion.com/thumbnail/video/${uid}`; }, height: 240 } }, { key: 'Gfycat', regExp: /^\w+:\/\/(?:www\.)?gfycat\.com\/(?:iframe\/)?(\w+)/, el(a) { const el = $.el('iframe', { src: `//gfycat.com/ifr/${a.dataset.uid}` }); el.setAttribute("allowfullscreen", "true"); return el; } }, { key: 'Gist', regExp: /^\w+:\/\/gist\.github\.com\/[\w\-]+\/(\w+)/, style: '', el: (function () { let counter = 0; return function (a) { const el = $.el('pre', { hidden: true, id: `gist-embed-${counter++}` }); CrossOrigin.cache(`https://api.github.com/gists/${a.dataset.uid}`, function () { el.textContent = Object.values(this.response.files)[0].content; el.className = 'prettyprint'; $.global('prettyPrint', { id: el.id }); return el.hidden = false; }); return el; }; })(), title: { api(uid) { return `https://api.github.com/gists/${uid}`; }, text({ files }) { for (var file in files) { if (files.hasOwnProperty(file)) { return file; } } } } }, { key: 'InstallGentoo', regExp: /^\w+:\/\/paste\.installgentoo\.com\/view\/(?:raw\/|download\/|embed\/)?(\w+)/, el(a) { return $.el('iframe', { src: `https://paste.installgentoo.com/view/embed/${a.dataset.uid}` }); } }, { key: 'LiveLeak', regExp: /^\w+:\/\/(?:\w+\.)?liveleak\.com\/.*\?.*[tif]=(\w+)/, el(a) { const el = $.el('iframe', { src: `https://www.liveleak.com/e/${a.dataset.uid}`, }); el.setAttribute("allowfullscreen", "true"); return el; } }, { key: 'Loopvid', regExp: /^\w+:\/\/(?:www\.)?loopvid.appspot.com\/#?((?:pf|kd|lv|gd|gh|db|dx|nn|cp|wu|ig|ky|mf|m2|pc|1c|pi|ni|wl|ko|mm|ic|gc)\/[\w\-\/]+(?:,[\w\-\/]+)*|fc\/\w+\/\d+|https?:\/\/.+)/, style: 'max-width: 80vw; max-height: 80vh;', el(a) { const el = $.el('video', { controls: true, preload: 'auto', loop: true }); if (/^http/.test(a.dataset.uid)) { $.add(el, $.el('source', { src: a.dataset.uid })); return el; } const [_, host, names] = a.dataset.uid.match(/(\w+)\/(.*)/); const types = (() => { switch (host) { case 'gd': case 'wu': case 'fc': return ['']; case 'gc': return ['giant', 'fat', 'zippy']; default: return ['.webm', '.mp4']; } })(); for (var name of names.split(',')) { for (var type of types) { var base = `${name}${type}`; var urls = (() => { switch (host) { case 'pf': return [`https://kastden.org/_loopvid_media/pf/${base}`, `https://web.archive.org/web/2/http://a.pomf.se/${base}`]; case 'kd': return [`https://kastden.org/loopvid/${base}`]; case 'lv': return [`https://lv.kastden.org/${base}`]; case 'gd': return [`https://docs.google.com/uc?export=download&id=${base}`]; case 'gh': return [`https://googledrive.com/host/${base}`]; case 'db': return [`https://dl.dropboxusercontent.com/u/${base}`]; case 'dx': return [`https://dl.dropboxusercontent.com/${base}`]; case 'nn': return [`https://kastden.org/_loopvid_media/nn/${base}`]; case 'cp': return [`https://copy.com/${base}`]; case 'wu': return [`http://webmup.com/${base}/vid.webm`]; case 'ig': return [`https://i.imgur.com/${base}`]; case 'ky': return [`https://kastden.org/_loopvid_media/ky/${base}`]; case 'mf': return [`https://kastden.org/_loopvid_media/mf/${base}`, `https://web.archive.org/web/2/https://d.maxfile.ro/${base}`]; case 'm2': return [`https://kastden.org/_loopvid_media/m2/${base}`]; case 'pc': return [`https://kastden.org/_loopvid_media/pc/${base}`, `https://web.archive.org/web/2/http://a.pomf.cat/${base}`]; case '1c': return [`http://b.1339.cf/${base}`]; case 'pi': return [`https://kastden.org/_loopvid_media/pi/${base}`, `https://web.archive.org/web/2/https://u.pomf.is/${base}`]; case 'ni': return [`https://kastden.org/_loopvid_media/ni/${base}`, `https://web.archive.org/web/2/https://u.nya.is/${base}`]; case 'wl': return [`http://webm.land/media/${base}`]; case 'ko': return [`https://kordy.kastden.org/loopvid/${base}`]; case 'mm': return [`https://kastden.org/_loopvid_media/mm/${base}`, `https://web.archive.org/web/2/https://my.mixtape.moe/${base}`]; case 'ic': return [`https://media.8ch.net/file_store/${base}`]; case 'fc': return [`//${ImageHost.host()}/${base}.webm`]; case 'gc': return [`https://${type}.gfycat.com/${name}.webm`]; } })(); if (!urls) { return el; } for (var url of urls) { $.add(el, $.el('source', { src: url })); } } } return el; } }, { key: 'Openings.moe', regExp: /^\w+:\/\/openings.moe\/\?video=([^.&=]+)/, style: 'width: 1280px; height: 720px; max-width: 80vw; max-height: 80vh;', el(a) { const el = $.el('iframe', { src: `https://openings.moe/?video=${a.dataset.uid}`, }); el.setAttribute("allowfullscreen", "true"); return el; } }, { key: 'Pastebin', regExp: /^\w+:\/\/(?:\w+\.)?pastebin\.com\/(?!u\/)(?:[\w.]+(?:\/|\?i\=))?(\w+)/, el(a) { return $.el('iframe', { src: `//pastebin.com/embed_iframe/${a.dataset.uid}` }); } }, { key: 'SoundCloud', regExp: /^\w+:\/\/(?:www\.)?(?:soundcloud\.com\/|snd\.sc\/)([\w\-\/]+)/, style: 'border: 0; width: 500px; height: 400px;', el(a) { return $.el('iframe', { src: `https://w.soundcloud.com/player/?visual=true&show_comments=false&url=https%3A%2F%2Fsoundcloud.com%2F${encodeURIComponent(a.dataset.uid)}` }); }, title: { api(uid) { return `${location.protocol}//soundcloud.com/oembed?format=json&url=https%3A%2F%2Fsoundcloud.com%2F${encodeURIComponent(uid)}`; }, text(_) { return _.title; } } }, { key: 'StrawPoll', regExp: /^\w+:\/\/(?:www\.)?strawpoll\.me\/(?:embed_\d+\/)?(\d+(?:\/r)?)/, style: 'border: 0; width: 600px; height: 406px;', el(a) { return $.el('iframe', { src: `https://www.strawpoll.me/embed_1/${a.dataset.uid}` }); } }, { key: 'Streamable', regExp: /^\w+:\/\/(?:www\.)?streamable\.com\/(\w+)/, el(a) { const el = $.el('iframe', { src: `https://streamable.com/o/${a.dataset.uid}` }); el.setAttribute("allowfullscreen", "true"); return el; }, title: { api(uid) { return `https://api.streamable.com/oembed?url=https://streamable.com/${uid}`; }, text(_) { return _.title; } } }, { key: 'TwitchTV', regExp: /^\w+:\/\/(?:www\.|secure\.|clips\.|m\.)?twitch\.tv\/(\w[^#\&\?]*)/, el(a) { let url; let m = a.dataset.href.match(/^\w+:\/\/(?:(clips\.)|\w+\.)?twitch\.tv\/(?:\w+\/)?(clip\/)?(\w[^#\&\?]*)/); if (m[1] || m[2]) { url = `//clips.twitch.tv/embed?clip=${m[3]}&parent=${location.hostname}`; } else { let time; m = a.dataset.uid.match(/(\w+)(?:\/(?:v\/)?(\d+))?/); url = `//player.twitch.tv/?${m[2] ? `video=v${m[2]}` : `channel=${m[1]}`}&autoplay=false&parent=${location.hostname}`; if (time = a.dataset.href.match(/\bt=(\w+)/)) { url += `&time=${time[1]}`; } } const el = $.el('iframe', { src: url }); el.setAttribute("allowfullscreen", "true"); return el; } }, { key: 'Twitter', regExp: /^\w+:\/\/(?:www\.|mobile\.)?(?:(?:(?:fx|vx)?twitter|(?:fixup|fixv)?x|twittpr|xcancel)\.com|nitter\.\w+.\w+)\/(\w+\/status\/\d+)/, style: 'border: none; width: 550px; height: 250px; overflow: hidden; resize: both;', el(a) { if (Conf.XEmbedder === 'tf') { const el = $.el('iframe'); $.on(el, 'load', function () { return this.contentWindow.postMessage({ element: 't', query: 'height' }, Conf.tfUrl); }); var onMessage = function (e) { if ((e.source === el.contentWindow) && (e.origin === Conf.tfUrl)) { $.off(window, 'message', onMessage); return (cont || el).style.height = `${+$.minmax(e.data.height, 250, 0.8 * doc.clientHeight)}px`; } }; $.on(window, 'message', onMessage); el.src = `${Conf.tfUrl}/show?url=https://twitter.com/${a.dataset.uid}`; if ($.engine === 'gecko') { el.style.cssText = 'border: none; width: 100%; height: 100%;'; var cont = $.el('div'); $.add(cont, el); return cont; } else { return el; } } return EmbedFxTwitter(a); }, }, { key: 'VidLii', regExp: /^\w+:\/\/(?:www\.)?vidlii\.com\/watch\?v=(\w{11})/, style: 'border: none; width: 640px; height: 392px;', el(a) { const el = $.el('iframe', { src: `https://www.vidlii.com/embed?v=${a.dataset.uid}&a=0` }); el.setAttribute("allowfullscreen", "true"); return el; } }, { key: 'Vimeo', regExp: /^\w+:\/\/(?:www\.)?vimeo\.com\/(\d+)/, el(a) { const el = $.el('iframe', { src: `//player.vimeo.com/video/${a.dataset.uid}?wmode=opaque` }); el.setAttribute("allowfullscreen", "true"); return el; }, title: { api(uid) { return `https://vimeo.com/api/oembed.json?url=https://vimeo.com/${uid}`; }, text(_) { return _.title; } } }, { key: 'Vine', regExp: /^\w+:\/\/(?:www\.)?vine\.co\/v\/(\w+)/, style: 'border: none; width: 500px; height: 500px;', el(a) { return $.el('iframe', { src: `https://vine.co/v/${a.dataset.uid}/card` }); } }, { key: 'Vocaroo', regExp: /^\w+:\/\/(?:(?:www\.|old\.)?vocaroo\.com|voca\.ro)\/((?:i\/)?\w+)/, style: '', el(a) { const el = $.el('iframe'); el.width = 300; el.height = 60; el.setAttribute('frameborder', 0); el.src = `https://vocaroo.com/embed/${a.dataset.uid.replace(/^i\//, '')}?autoplay=0`; return el; } }, { key: 'YouTube', regExp: /^\w+:\/\/(?:youtu.be\/|(?:[\w.]*youtube[\w.]*|yewtu\.be)\/.*(?:v=|\bembed\/|\bv\/|shorts\/|live\/|watch\/))([\w\-]{11})(.*)/, el(a) { let start = a.dataset.options.match(/\b(?:star)?t\=(\w+)/); if (start) { start = start[1]; } if (start && !/^\d+$/.test(start)) { start += ' 0h0m0s'; start = (3600 * start.match(/(\d+)h/)[1]) + (60 * start.match(/(\d+)m/)[1]) + (1 * start.match(/(\d+)s/)[1]); } const el = $.el('iframe', { src: `//www.youtube.com/embed/${a.dataset.uid}?rel=0&wmode=opaque${start ? '&start=' + start : ''}` }); el.setAttribute("allowfullscreen", "true"); return el; }, title: { api(uid) { return `https://www.youtube.com/oembed?url=https%3A//www.youtube.com/watch%3Fv%3D${uid}&format=json`; }, text(_) { return _.title; }, status(_) { if (_.error) { const m = _.error.match(/^(\d*)\s*(.*)/); return [+m[1], m[2]]; } else { return [200, 'OK']; } } }, preview: { url(uid) { return `https://img.youtube.com/vi/${uid}/0.jpg`; }, height: 360 } } ], shouldFetchTitles() { if (!Conf['Link Title']) return false; if (Conf['Link Title in the catalog']) return true; return g.VIEW !== 'catalog' && !(g.VIEW === 'index' && Conf['Index Mode'] === 'catalog'); }, }; const X_HOST_RE = /\b((?:www\.|mobile\.)?(?:fx|vx)?twitter\.com|(?:www\.|mobile\.)?(?:fixup|fixv)?x\.com|twittpr\.com)\b/gi; const YOUTUBE_HOST_RE = /\b((?:www\.|m\.|music\.|mobile\.)?(?:youtu\.be|youtube\.com|youtube-nocookie\.com))\b/gi; var Linkify = { init() { if (g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive') { return; } $.sync('Convert X to xcancel', enabled => { Conf['Convert X to xcancel'] = enabled; Linkify.refreshFrontEndRewrites(); }); $.sync('Convert YouTube to yewtu.be', enabled => { Conf['Convert YouTube to yewtu.be'] = enabled; Linkify.refreshFrontEndRewrites(); }); const shouldLinkify = Conf['Linkify']; const shouldRewriteX = Conf['Convert X to xcancel']; const shouldRewriteYouTube = Conf['Convert YouTube to yewtu.be']; if (!shouldLinkify && !shouldRewriteX && !shouldRewriteYouTube) { return; } if (shouldLinkify && Conf['Comment Expansion']) { ExpandComment.callbacks.push(this.node); } Callbacks.Post.push({ name: 'Linkify', cb: this.node }); if (shouldLinkify) { return Embedding.init(); } }, refreshFrontEndRewrites() { const shouldRewriteX = Conf['Convert X to xcancel']; const shouldRewriteYouTube = Conf['Convert YouTube to yewtu.be']; if (shouldRewriteX || shouldRewriteYouTube) { const selector = g.SITE?.selectors?.comment; if (!selector) { return; } for (const comment of $$(selector)) { for (const link of $$('a', comment)) { if (shouldRewriteX) { Linkify.rewriteXLink(link); } else if (link.dataset.xcancelOrigHref) { link.href = link.dataset.xcancelOrigHref; if (link.dataset.xcancelOrigText != null && link.children.length === 0) { link.textContent = link.dataset.xcancelOrigText; } delete link.dataset.xcancelOrigHref; delete link.dataset.xcancelOrigText; } if (shouldRewriteYouTube) { Linkify.rewriteYouTubeLink(link); } else if (link.dataset.yewtuOrigHref) { link.href = link.dataset.yewtuOrigHref; if (link.dataset.yewtuOrigText != null && link.children.length === 0) { link.textContent = link.dataset.yewtuOrigText; } delete link.dataset.yewtuOrigHref; delete link.dataset.yewtuOrigText; } } } return; } for (const link of $$('a[data-xcancel-orig-href]')) { link.href = link.dataset.xcancelOrigHref; if (link.dataset.xcancelOrigText != null && link.children.length === 0) { link.textContent = link.dataset.xcancelOrigText; } delete link.dataset.xcancelOrigHref; delete link.dataset.xcancelOrigText; } for (const link of $$('a[data-yewtu-orig-href]')) { link.href = link.dataset.yewtuOrigHref; if (link.dataset.yewtuOrigText != null && link.children.length === 0) { link.textContent = link.dataset.yewtuOrigText; } delete link.dataset.yewtuOrigHref; delete link.dataset.yewtuOrigText; } }, node() { let link; if (this.isClone) { return Embedding.events(this); } if (!Linkify.regString.test(this.info.comment)) { if (Conf['Convert X to xcancel'] || Conf['Convert YouTube to yewtu.be']) { for (link of $$('a', this.nodes.comment)) { if (Conf['Convert X to xcancel']) { Linkify.rewriteXLink(link); } if (Conf['Convert YouTube to yewtu.be']) { Linkify.rewriteYouTubeLink(link); } } } return; } for (link of $$('a', this.nodes.comment)) { if (Conf['Convert X to xcancel']) { Linkify.rewriteXLink(link); } if (Conf['Convert YouTube to yewtu.be']) { Linkify.rewriteYouTubeLink(link); } if (g.SITE?.isLinkified?.(link)) { $.addClass(link, 'linkify'); if (ImageHost.useFaster) { ImageHost.fixLinks([link]); } Embedding.process(link, this); } } const links = Linkify.process(this.nodes.comment); if (ImageHost.useFaster) { ImageHost.fixLinks(links); } for (link of links) { Embedding.process(link, this); } }, process(node) { let length; const test = /[^\s"]+/g; const space = /[\s"]/; const snapshot = $.X('.//br|.//text()', node); let i = 0; const links = []; while ((node = snapshot.snapshotItem(i++))) { var result; var { data } = node; if (!data || (node.parentElement.nodeName === "A")) { continue; } while ((result = test.exec(data))) { var { index } = result; var endNode = node; var word = result[0]; if ((length = index + word.length) === data.length) { var saved; test.lastIndex = 0; while (saved = snapshot.snapshotItem(i++)) { var end; if ((saved.nodeName === 'BR') || ((saved.parentElement.nodeName === 'P') && !saved.previousSibling)) { var part1, part2; if ( (part1 = word.match(/(https?:\/\/)?([a-z\d-]+\.)*[a-z\d-]+$/i)) && (part2 = snapshot.snapshotItem(i)?.data?.match(/^(\.[a-z\d-]+)*\//i)) && ((part1[0] + part2[0]).search(Linkify.regString) === 0)) { continue; } else { break; } } if ((saved.parentElement.nodeName === "A") && !Linkify.regString.test(word)) { break; } endNode = saved; ({ data } = saved); if (end = space.exec(data)) { word += data.slice(0, end.index); test.lastIndex = (length = end.index); i--; break; } else { ({ length } = data); word += data; } } } if (Linkify.regString.test(word)) { links.push(Linkify.makeRange(node, endNode, index, length)); } if (!test.lastIndex || (node !== endNode)) { break; } } } i = links.length; while (i--) { links[i] = Linkify.makeLink(links[i]); } return links; }, regString: new RegExp(`(\ \ (https?|mailto|git|magnet|ftp|irc):(\ [a-z\\d%/?]\ )\ |\ ([-a-z\\d]+[.])+(\ aero|asia|biz|cat|com|coop|dance|info|int|jobs|mobi|moe|museum|name|net|org|post|pro|tel|travel|xxx|xyz|edu|gov|mil|[a-z]{2}\ )([:/]|(?![^\\s"]))\ |\ [\\d]{1,3}\\.[\\d]{1,3}\\.[\\d]{1,3}\\.[\\d]{1,3}\ |\ [-\\w\\d.@]+@[a-z\\d.-]+\\.[a-z\\d]\ )`, 'i'), makeRange(startNode, endNode, startOffset, endOffset) { const range = document.createRange(); range.setStart(startNode, startOffset); range.setEnd(endNode, endOffset); return range; }, makeLink(range) { let t; let encodedDomain; let text = range.toString(); let i = text.search(Linkify.regString); if (i > 0) { text = text.slice(i); while ((range.startOffset + i) >= range.startContainer.data.length) { i--; } if (i) { range.setStart(range.startContainer, range.startOffset + i); } } i = 0; while (/[)\]}>.,]/.test(t = text.charAt(text.length - (1 + i)))) { if (!/[.,]/.test(t) && !((text.match(/[()\[\]{}<>]/g)).length % 2)) { break; } i++; } if (i) { text = text.slice(0, -i); while ((range.endOffset - i) < 0) { i--; } if (i) { range.setEnd(range.endContainer, range.endOffset - i); } } if (!/((mailto|magnet):|.+:\/\/)/.test(text)) { text = (/@/.test(text) ? 'mailto:' : 'http://') + text; } if (encodedDomain = text.match(/^(https?:\/\/[^/]*%[0-9a-f]{2})(.*)$/i)) { text = encodedDomain[1].replace(/%([0-9a-f]{2})/ig, function (x, y) { if (y === '25') { return x; } else { return String.fromCharCode(parseInt(y, 16)); } }) + encodedDomain[2]; } const rewrittenHref = Linkify.rewriteURLs(text); const a = $.el('a', { className: 'linkify', rel: 'noreferrer noopener', target: '_blank', href: rewrittenHref }); $.add(a, range.extractContents()); range.insertNode(a); if (rewrittenHref !== text) { if (Conf['Convert X to xcancel'] && Linkify.rewriteXURL(text) !== text) { a.dataset.xcancelOrigHref = text; if (a.children.length === 0) { a.dataset.xcancelOrigText = a.textContent || ''; } } else if (Conf['Convert YouTube to yewtu.be'] && Linkify.rewriteYouTubeURL(text) !== text) { a.dataset.yewtuOrigHref = text; if (a.children.length === 0) { a.dataset.yewtuOrigText = a.textContent || ''; } } Linkify.rewriteVisibleText(a); } return a; }, rewriteXLink(link) { if (!Conf['Convert X to xcancel']) { return; } const oldHref = link.href; const newHref = Linkify.rewriteXURL(oldHref); if (newHref !== oldHref) { if (!link.dataset.xcancelOrigHref) { link.dataset.xcancelOrigHref = oldHref; if (link.children.length === 0) { link.dataset.xcancelOrigText = link.textContent; } } link.href = newHref; Linkify.rewriteVisibleText(link); } }, rewriteYouTubeLink(link) { if (!Conf['Convert YouTube to yewtu.be']) { return; } const oldHref = link.href; const newHref = Linkify.rewriteYouTubeURL(oldHref); if (newHref !== oldHref) { if (!link.dataset.yewtuOrigHref) { link.dataset.yewtuOrigHref = oldHref; if (link.children.length === 0) { link.dataset.yewtuOrigText = link.textContent; } } link.href = newHref; Linkify.rewriteVisibleText(link); } }, rewriteVisibleText(link) { const walker = document.createTreeWalker(link, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { const updated = Linkify.rewriteDisplayText(node.data); if (updated !== node.data) { node.data = updated; } } }, rewriteDisplayText(text) { let updated = text; if (Conf['Convert X to xcancel']) { updated = updated.replace(X_HOST_RE, 'xcancel.com'); } if (Conf['Convert YouTube to yewtu.be']) { updated = updated.replace(YOUTUBE_HOST_RE, 'yewtu.be'); } return updated; }, rewriteXURL(urlString) { if (!Conf['Convert X to xcancel']) { return urlString; } try { const base = (typeof location === 'object' && location?.href) ? location.href : undefined; const url = base ? new URL(urlString, base) : new URL(urlString); if (!/^https?:$/.test(url.protocol)) { return urlString; } if (/(?:^|\.)twitter\.com$/i.test(url.hostname) || /(?:^|\.)x\.com$/i.test(url.hostname)) { url.hostname = 'xcancel.com'; return url.toString(); } const redirectParams = ['url', 'u', 'to', 'target', 'dest', 'destination', 'redirect', 'redir', 'r']; for (const key of redirectParams) { const value = url.searchParams.get(key); if (!value) { continue; } const rewritten = Linkify.rewriteXURL(value); if (rewritten !== value) { url.searchParams.set(key, rewritten); return url.toString(); } } } catch { } return urlString; }, rewriteYouTubeURL(urlString) { if (!Conf['Convert YouTube to yewtu.be']) { return urlString; } try { const base = (typeof location === 'object' && location?.href) ? location.href : undefined; const url = base ? new URL(urlString, base) : new URL(urlString); if (!/^https?:$/.test(url.protocol)) { return urlString; } const isYouTube = /(?:^|\.)youtube\.com$/i.test(url.hostname) || /(?:^|\.)youtube-nocookie\.com$/i.test(url.hostname) || /^(?:www\.)?youtu\.be$/i.test(url.hostname); if (isYouTube) { if (/^(?:www\.)?youtu\.be$/i.test(url.hostname)) { const shortId = url.pathname.replace(/^\//, ''); if (/^[\w-]{11}$/.test(shortId)) { if (!url.searchParams.get('v')) { url.searchParams.set('v', shortId); } url.pathname = '/watch'; } } url.hostname = 'yewtu.be'; return url.toString(); } const redirectParams = ['url', 'u', 'to', 'target', 'dest', 'destination', 'redirect', 'redir', 'r']; for (const key of redirectParams) { const value = url.searchParams.get(key); if (!value) { continue; } const rewritten = Linkify.rewriteYouTubeURL(value); if (rewritten !== value) { url.searchParams.set(key, rewritten); return url.toString(); } } } catch { } return urlString; }, rewriteURLs(urlString) { const rewrittenX = Linkify.rewriteXURL(urlString); return Linkify.rewriteYouTubeURL(rewrittenX); } }; var BoardConfig = { cbs: [], init() { let middle; if (g.SITE?.software !== 'yotsuba') { return; } const now = Date.now(); if (now - (2 * HOUR) >= ((middle = Conf['boardConfig'].lastChecked || 0)) || middle > now) { return $.ajax(`${location.protocol}//a.4cdn.org/boards.json`, { onloadend: this.load }); } else { const { boards } = Conf['boardConfig']; return this.set(boards); } }, load() { let boards; if ((this.status === 200) && this.response && this.response.boards) { boards = dict(); for (var board of this.response.boards) { boards[board.board] = board; } $.set('boardConfig', { boards, lastChecked: Date.now() }); } else { ({ boards } = Conf['boardConfig']); const err = (() => { switch (this.status) { case 0: return 'Connection Error'; case 200: return 'Invalid Data'; default: return `Error ${this.statusText} (${this.status})`; } })(); new Notice('warning', `Failed to load board configuration. ${err}`, 20); } return BoardConfig.set(boards); }, set(boards) { this.boards = boards; for (var ID in g.boards) { var board = g.boards[ID]; board.config = this.boards[ID] || {}; } for (var cb of this.cbs) { $.queueTask(cb); } }, ready(cb) { if (this.boards) { return cb(); } else { return this.cbs.push(cb); } }, sfwBoards(sfw) { return (() => { const result = []; const object = this.boards || Conf['boardConfig'].boards; for (var board in object) { var data = object[board]; if (!!data.ws_board === sfw) { result.push(board); } } return result; })(); }, isSFW(board) { return !!(this.boards || Conf['boardConfig'].boards)[board]?.ws_board; }, domain(board) { return 'boards.4chan.org'; }, isArchived(board) { const data = (this.boards || Conf['boardConfig'].boards)[board]; return !data || data.is_archived; }, noAudio(boardID) { if (g.SITE?.software !== 'yotsuba') { return false; } const boards = this.boards || Conf['boardConfig'].boards; return boards && boards[boardID] && !boards[boardID].webm_audio; }, title(boardID) { return (this.boards || Conf['boardConfig'].boards)?.[boardID]?.title || ''; } }; const LONG_PRESS_MS = 500; const LONG_PRESS_MOVE_LIMIT = 10; var Menu = { button: null, menu: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu']) { return; } this.button = $.el('a', { className: 'menu-button', href: 'javascript:;' }); Icon.set(this.button, 'caretDown'); this.menu = new UI.Menu('post'); this.initLongPress(); Callbacks.Post.push({ name: 'Menu', cb: this.node }); return Callbacks.CatalogThread.push({ name: 'Menu', cb: this.catalogNode }); }, node() { if (this.isClone) { const button = $('.menu-button', this.nodes.info); $.rmClass(button, 'active'); $.rm($('.dialog', this.nodes.info)); Menu.makeButton(this, button); return; } return $.add(this.nodes.info, Menu.makeButton(this)); }, catalogNode() { return $.after(this.nodes.icons, Menu.makeButton(this.thread.OP)); }, makeButton(post, button) { if (!button) { button = Menu.button.cloneNode(true); } $.on(button, 'click', function (e) { return Menu.menu.toggle(e, this, post); }); return button; }, initLongPress() { let timer = null; let pressRoot = null; let pressedEl = null; let startX = 0; let startY = 0; let fired = false; let firing = false; let suppressClick = false; const setPressed = (el) => { if (pressedEl) { $.rmClass(pressedEl, 'xt-press', 'xt-menu-target'); } pressedEl = el; if (el) { $.addClass(el, 'xt-press'); } }; const cancel = () => { if (timer) { clearTimeout(timer); timer = null; } pressRoot = null; if (!fired) { setPressed(null); } }; const fire = (e) => { timer = null; const root = pressRoot; pressRoot = null; if (!root) return; const post = Get.postFromNode(root); if (!post) return; fired = true; suppressClick = true; if (pressedEl) { $.addClass(pressedEl, 'xt-menu-target'); } firing = true; Menu.menu.toggleAtPoint(e, { x: startX, y: startY }, post); firing = false; }; $.on(doc, 'pointerdown', (e) => { fired = false; suppressClick = false; if (e.pointerType !== 'touch' || !doc.classList.contains('xt-mobile')) return; let target = e.target; if (target?.id === 'menu-backdrop') { target = document.elementsFromPoint(e.clientX, e.clientY) .find(el => el.id !== 'menu-backdrop' && !el.closest('#menu')) || target; } if (!target?.closest) return; const blocked = target.closest('.catalog-thread') ? 'input, textarea, select, button, .menu-button, #menu, #qr' : 'a, input, textarea, select, button, img, video, audio, iframe, .menu-button, #menu, #qr'; if (target.closest(blocked)) return; const root = target.closest(g.SITE.selectors.postContainer); if (!root) return; pressRoot = root; setPressed(root); startX = e.clientX; startY = e.clientY; timer = setTimeout(() => fire(e), LONG_PRESS_MS); }); $.on(doc, 'pointermove', (e) => { if (!pressRoot || e.pointerType !== 'touch') return; if (Math.abs(e.clientX - startX) > LONG_PRESS_MOVE_LIMIT || Math.abs(e.clientY - startY) > LONG_PRESS_MOVE_LIMIT) { cancel(); } }); $.on(doc, 'pointerup pointercancel', (e) => { if (e.pointerType === 'touch') cancel(); }); $.on(window, 'scroll', cancel); doc.addEventListener('contextmenu', (e) => { if (fired || pressRoot || e.target?.id === 'menu-backdrop') { e.preventDefault(); fired = false; } }); doc.addEventListener('click', (e) => { if (suppressClick) { suppressClick = false; e.preventDefault(); e.stopPropagation(); } }, true); $.on(d, 'MenuClosed', () => { if (!firing && !timer && !pressRoot) { setPressed(null); } }); } }; var Recursive = { recursives: new Map(), init() { if (g.VIEW !== 'index' && g.VIEW !== 'thread') return; Callbacks.Post.push({ name: 'Recursive', cb: this.node }); }, node() { if (this.isClone || this.isFetchedQuote) return; for (var quote of this.quotes) { const obj = Recursive.recursives.get(quote); if (obj) { for (var i = 0; i < obj.recursives.length; i++) { obj.recursives[i](this, ...obj.args[i]); } } } }, add(recursive, post, ...args) { let obj = Recursive.recursives.get(post.fullID); if (!obj) { obj = { recursives: [], args: [] }; Recursive.recursives.set(post.fullID, obj); } obj.recursives.push(recursive); obj.args.push(args); }, rm(recursive, post) { const obj = Recursive.recursives.get(post.fullID); if (!obj) return; for (let i = obj.recursives.length - 1; i >= 0; --i) { if (obj.recursives[i] === recursive) { obj.recursives.splice(i, 1); obj.args.splice(i, 1); } } }, apply(recursive, post, ...args) { const { fullID } = post; g.posts.forEach(function (post) { if (post.quotes.includes(fullID)) { recursive(post, ...args); } }); }, applyAndAdd(recursive, post, ...args) { Recursive.apply(recursive, post, ...args); this.add(recursive, post, ...args); }, }; var PostHiding = { db: undefined, posterIdDb: undefined, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || (!Conf['Reply Hiding Buttons'] && !(Conf['Menu'] && Conf['Reply Hiding Link']))) { return; } if (Conf['Reply Hiding Buttons']) { $.addClass(doc, "reply-hide"); } this.db = new DataBoard('hiddenPosts'); this.posterIdDb = new DataBoard('hiddenPosterIds'); Callbacks.Post.push({ name: 'Reply Hiding', cb: this.node }); }, isHidden(boardID, threadID, postID) { return !!(PostHiding.db && PostHiding.db.get({ boardID, threadID, postID })); }, node() { if (!this.isReply || this.isClone || this.isFetchedQuote) return; let data = PostHiding.db.get({ boardID: this.board.ID, threadID: this.thread.ID, postID: this.ID }); if (!data && this.info.uniqueID) { const hiddenPosterIds = PostHiding.posterIdDb.get({ boardID: this.board.ID, threadID: this.thread.ID }); if (hiddenPosterIds && this.info.uniqueID in hiddenPosterIds) { data = hiddenPosterIds[this.info.uniqueID]; data.thisPost = true; } } if (data) { if (data.thisPost) { PostHiding.hide(this, data.makeStub, data.hideRecursively, 'Hidden manually'); } else { PostHiding.hideRecursive(this, data.makeStub ?? Conf['Stubs']); } } if (!Conf['Reply Hiding Buttons']) { return; } const button = PostHiding.makeButton(this, 'hide'); const sa = g.SITE.selectors.sideArrows; if (sa) { const sideArrows = $(sa, this.nodes.root); $.replace(sideArrows.firstChild, button); sideArrows.className = 'replacedSideArrows'; } else { $.prepend(this.nodes.info, button); } }, menu: { post: undefined, async init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu'] || !Conf['Reply Hiding Link']) return; await new Promise(res => BoardConfig.ready(res)); let applyHide = $.el('a', { textContent: 'Apply', href: 'javascript:;' }); $.on(applyHide, 'click', PostHiding.menu.hide); const hideOptions = [ { el: applyHide }, { el: UI.checkbox('thisPost', 'This post', true) }, { el: UI.checkbox('replies', 'Hide replies', Conf['Recursive Hiding']) }, { el: UI.checkbox('makeStub', 'Make stub', Conf['Stubs']) }, ]; if (g.BOARD.config.user_ids) { hideOptions.push({ el: UI.checkbox('byId', 'By poster id', false) }); } Menu.menu.addEntry({ el: $.el('div', { className: 'hide-reply-link', textContent: 'Hide' }), order: 20, open(post) { if (!post.isReply || post.isClone || post.isHidden) { return false; } PostHiding.menu.post = post; return true; }, subEntries: hideOptions }); const applyShow = $.el('a', { textContent: 'Apply', href: 'javascript:;' }); $.on(applyShow, 'click', PostHiding.menu.show); const thisPost = UI.checkbox('thisPost', 'This post', false); const replies = UI.checkbox('replies', 'Show replies', false); const hideStubLink = $.el('a', { textContent: 'Hide stub', href: 'javascript:;' }); $.on(hideStubLink, 'click', PostHiding.menu.hideStub); const showOptions = [ { el: applyShow }, { el: thisPost }, { el: replies }, ]; let byId; if (g.BOARD.config.user_ids) { byId = UI.checkbox('byId', 'By poster id', false); showOptions.push({ el: byId }); } Menu.menu.addEntry({ el: $.el('div', { className: 'show-reply-link', textContent: 'Show' }), order: 20, open(post) { if (!post.isReply || post.isClone || !post.isHidden) { return false; } const data = PostHiding.db.get({ boardID: post.board.ID, threadID: post.thread.ID, postID: post.ID }); if (!data) return false; PostHiding.menu.post = post; thisPost.firstChild.checked = post.isHidden; replies.firstChild.checked = data.hideRecursively ?? Conf['Recursive Hiding']; if (byId) byId.firstChild.checked = data.byId; return true; }, subEntries: showOptions }); Menu.menu.addEntry({ el: hideStubLink, order: 15, open(post) { if (!post.isReply || post.isClone || !post.isHidden) { return false; } if (!(PostHiding.db.get({ boardID: post.board.ID, threadID: post.thread.ID, postID: post.ID }))) { return false; } return PostHiding.menu.post = post; } }); }, hide() { const parent = this.parentNode; const thisPost = $('input[name=thisPost]', parent).checked; const replies = $('input[name=replies]', parent).checked; const makeStub = $('input[name=makeStub]', parent).checked; const byId = $('input[name=byId]', parent)?.checked; const { post } = PostHiding.menu; if (!thisPost && !replies && !byId) return; if (thisPost) { PostHiding.hide(post, makeStub, replies, 'Hidden manually'); } else if (replies) { PostHiding.hideRecursive(post, makeStub); } if (byId) { if (!post.info.uniqueID) return; const msg = `Hidden because of poster ID ${post.info.uniqueID}`; g.posts.forEach((p) => { if (p.info.uniqueID === post.info.uniqueID && p !== post) { PostHiding.hide(p, makeStub, replies, msg); PostHiding.saveHiddenState(p, true, thisPost, makeStub, replies, byId); } }); const data = PostHiding.posterIdDb.get({ boardID: post.boardID, threadID: post.threadID, defaultValue: dict() }); if (!(post.info.uniqueID in data)) { data[post.info.uniqueID] = { makeStub, hideRecursively: replies }; PostHiding.posterIdDb.set({ boardID: post.boardID, threadID: post.threadID, val: data }); } } PostHiding.saveHiddenState(post, true, thisPost, makeStub, replies, byId); $.event('CloseMenu'); }, show() { const parent = this.parentNode; const thisPost = $('input[name=thisPost]', parent).checked; const replies = $('input[name=replies]', parent).checked; const byId = $('input[name=byId]', parent)?.checked; const { post } = PostHiding.menu; const { boardID, threadID, postID } = post; if (!thisPost && !replies && !byId) return; if (thisPost) { PostHiding.show(post, replies); } else if (replies) { Recursive.apply(PostHiding.show, post, true); Recursive.rm(PostHiding.hide, post); } if (byId) { if (!post.info.uniqueID) return; g.posts.forEach((p) => { if (p.info.uniqueID === post.info.uniqueID && p !== post) { PostHiding.show(p, replies); const data = PostHiding.db.get({ boardID, threadID, postID }); if (data) { PostHiding.saveHiddenState(post, !(thisPost && replies), !thisPost, data.makeStub, !replies, byId); } } }); const byIdState = PostHiding.posterIdDb.get({ boardID, threadID }); if (byIdState && post.info.uniqueID in byIdState) { delete byIdState[post.info.uniqueID]; PostHiding.posterIdDb.set({ boardID, threadID, val: byIdState }); } } const data = PostHiding.db.get({ boardID, threadID, postID }); if (data) { PostHiding.saveHiddenState(post, !(thisPost && replies), !thisPost, data.makeStub, !replies, byId); } $.event('CloseMenu'); }, hideStub() { let data; const { post } = PostHiding.menu; if (data = PostHiding.db.get({ boardID: post.board.ID, threadID: post.thread.ID, postID: post.ID })) { PostHiding.show(post, data.hideRecursively); PostHiding.hide(post, false, data.hideRecursively); PostHiding.saveHiddenState(post, true, true, false, data.hideRecursively, data.byId); } $.event('CloseMenu'); } }, makeButton(post, type) { const span = $.el('span', { className: 'stub-icon', }); const a = $.el('a', { className: `${type}-post-button ${type}-reply-button`, href: 'javascript:;' }); Icon.set(span, type === 'hide' ? 'squareMinus' : 'squarePlus'); $.add(a, span); $.on(a, 'click', PostHiding.toggle); return a; }, saveHiddenState(post, isHiding, thisPost, makeStub, hideRecursively, byId) { const data = { boardID: post.board.ID, threadID: post.thread.ID, postID: post.ID }; if (isHiding) { data.val = { thisPost: thisPost !== false, makeStub, hideRecursively, byId }; PostHiding.db.set(data); } else { PostHiding.db.delete(data); } }, toggle() { const post = Get.postFromNode(this); if (!post) return; post.isHidden ? PostHiding.show(post) : PostHiding.hide(post, undefined, undefined, 'Hidden manually'); PostHiding.saveHiddenState(post, post.isHidden); }, hide(post, makeStub = Conf['Stubs'], hideRecursively = Conf['Recursive Hiding'], reason) { if (post.isHidden) return; post.isHidden = true; if (hideRecursively) { PostHiding.hideRecursive(post, makeStub); } for (var quotelink of Get.allQuotelinksLinkingTo(post)) { $.addClass(quotelink, 'filtered'); } if (!makeStub) { post.nodes.root.hidden = true; return; } post.nodes.stub = $.el('div', { className: 'stub' }); const a = PostHiding.makeButton(post, 'show'); $.add(a, $.el('span', { className: 'stub-name', textContent: post.info.nameBlock })); let reasons = post.filterResults?.reasons || []; if (reason) reasons = [...reasons, reason]; if (Conf['Filter Reason'] && reasons.length) { const reasonsSpan = $.el('span', { className: 'stub-reasons' }); $.add(reasonsSpan, reasons.map(re => $.el('span', { className: 'stub-reason', textContent: re }))); a.appendChild(reasonsSpan); } $.add(post.nodes.stub, a); if (!Conf['Filter Reason'] && reasons) post.nodes.stub.title = reasons.join(' & '); if (Conf['Menu']) { $.add(post.nodes.stub, Menu.makeButton(post)); } $.prepend(post.nodes.root, post.nodes.stub); }, hideRecursive(post, makeStub) { Recursive.applyAndAdd(PostHiding.hide, post, makeStub, true, `Hidden recursively from ${post.ID}`); }, show(post, showRecursively = Conf['Recursive Hiding']) { if (post.nodes.stub) { $.rm(post.nodes.stub); delete post.nodes.stub; } else { post.nodes.root.hidden = false; } post.isHidden = false; if (showRecursively) { Recursive.apply(PostHiding.show, post, true); Recursive.rm(PostHiding.hide, post); } for (var quotelink of Get.allQuotelinksLinkingTo(post)) { $.rmClass(quotelink, 'filtered'); } } }; var RelativeDates = { INTERVAL: 30000, callbackRegistered: false, listenersBound: false, init() { RelativeDates.ensureListeners(); $.on(d, 'RelativePostDatesChanged', RelativeDates.reapply); $.sync('RelativeTime', (RelativeTime) => { Conf.RelativeTime = RelativeTime; RelativeDates.reapply(); }); if (Conf.RelativeTime !== 'No') { return RelativeDates.ensureCallback(); } }, ensureListeners() { if (RelativeDates.listenersBound) return; RelativeDates.listenersBound = true; $.on(d, 'visibilitychange PostsInserted', RelativeDates.flush); }, ensureCallback() { if (RelativeDates.callbackRegistered) return; RelativeDates.callbackRegistered = true; return Callbacks.Post.push({ name: 'Relative Post Dates', cb: RelativeDates.node }); }, node() { if (Conf.RelativeTime === 'No') { return; } if (!this.info.date) { return; } const dateEl = this.nodes.date; if (Conf.RelativeTime === 'Hover') { RelativeDates.bindHover(this); return; } if (this.isClone) { return; } if (Conf.RelativeTime === 'Show') { dateEl.dataset.fullTime = dateEl.textContent; dateEl.title = dateEl.textContent; } return RelativeDates.update(this); }, bindHover(post) { const dateEl = post.nodes.date; if (dateEl.dataset.relativeHoverBound) return; dateEl.dataset.relativeHoverBound = '1'; $.on(dateEl, 'mouseover', () => RelativeDates.hover(post)); }, reapply() { clearTimeout(RelativeDates.timeout); RelativeDates.timeout = undefined; RelativeDates.stale = []; if (g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive') return; const mode = Conf.RelativeTime; if (mode !== 'No') { RelativeDates.ensureListeners(); RelativeDates.ensureCallback(); } g.posts.forEach((post) => { if (!post.info.date || post.isFetchedQuote || !doc.contains(post.nodes.root)) return; RelativeDates.restore(post); if (mode === 'No') return; if (mode === 'Hover') { RelativeDates.bindHover(post); return; } if (!post.isClone) RelativeDates.update(post); }); if (['Show', 'Both', 'BothRelativeFirst'].includes(mode)) { RelativeDates.flush(); } }, restore(post) { for (const singlePost of [post].concat(post.clones)) { const node = singlePost.nodes.date; const full = node.dataset.fullTime; if (full) node.textContent = full; node.removeAttribute('title'); } }, relative(diff, now, date, abbrev = false) { let number; let unit; if ((number = (diff / DAY)) >= 1) { const years = now.getFullYear() - date.getFullYear(); let months = now.getMonth() - date.getMonth(); const days = now.getDate() - date.getDate(); if (years > 1) { number = years - ((months < 0) || ((months === 0) && (days < 0)) ? 1 : 0); unit = 'year'; } else if ((years === 1) && ((months > 0) || ((months === 0) && (days >= 0)))) { number = years; unit = 'year'; } else if ((months = months + (12 * years)) > 1) { number = months - (days < 0 ? 1 : 0); unit = 'month'; } else if ((months === 1) && (days >= 0)) { number = months; unit = 'month'; } else { unit = 'day'; } } else if ((number = (diff / HOUR)) >= 1) { unit = 'hour'; } else if ((number = (diff / MINUTE)) >= 1) { unit = 'minute'; } else { number = Math.max(0, diff) / SECOND; unit = 'second'; } const rounded = Math.round(number); if (abbrev) { unit = unit === 'month' ? 'mo' : unit[0]; } else { if (rounded !== 1) { unit += 's'; } } if (abbrev) { return `${rounded}${unit}`; } else { return `${rounded} ${unit} ago`; } }, stale: [], timeout: undefined, flush() { if (d.hidden) { return; } const now = new Date(); for (var data of RelativeDates.stale) { RelativeDates.update(data, now); } RelativeDates.stale = []; clearTimeout(RelativeDates.timeout); RelativeDates.timeout = window.setTimeout(RelativeDates.flush, RelativeDates.INTERVAL); }, hover(post) { const date = post.info.date; const now = new Date(); const diff = now.getTime() - date.getTime(); post.nodes.date.title = RelativeDates.relative(diff, now, date); }, update(data, now = new Date()) { let abbrev, date; const isPost = data instanceof Post; if (isPost) { date = data.info.date; if (!date) { return; } abbrev = false; } else { const utc = data.dataset.utc; if (!utc) { return; } date = new Date(+utc); abbrev = !!data.dataset.abbrev; } const diff = now.getTime() - date.getTime(); const relative = RelativeDates.relative(diff, now, date, abbrev); if (isPost) { for (var singlePost of [data].concat(data.clones)) { const node = singlePost.nodes.date; if (Conf.RelativeTime === 'Show') { node.textContent = relative; } else { let full = node.dataset.fullTime; if (!full) { full = node.textContent; node.dataset.fullTime = full; } node.textContent = Conf.RelativeTime === 'Both' ? `${full}, ${relative}` : `${relative}, ${full}`; } } } else { if (data.firstChild) { data.firstChild.textContent = relative; } } RelativeDates.setOwnTimeout(diff, data); }, setOwnTimeout(diff, data) { const delay = diff < MINUTE ? SECOND - ((diff + (SECOND / 2)) % SECOND) : diff < HOUR ? MINUTE - ((diff + (MINUTE / 2)) % MINUTE) : diff < DAY ? HOUR - ((diff + (HOUR / 2)) % HOUR) : DAY - ((diff + (DAY / 2)) % DAY); setTimeout(RelativeDates.markStale, delay, data); }, markStale(data) { if (RelativeDates.stale.includes(data)) { return; } if (data instanceof Post && !g.posts.get(data.fullID)) { return; } if (data instanceof Element && !doc.contains(data)) { return; } RelativeDates.stale.push(data); } }; var ThreadWatcherPage = `
Thread Watcher ×
`; class Board { toString() { return this.ID; } constructor(ID) { this.ID = ID; this.boardID = this.ID; this.siteID = g.SITE.ID; this.threads = new SimpleDict(); this.posts = new SimpleDict(); this.config = BoardConfig.boards?.[this.ID] || {}; g.boards[this] = this; } cooldowns() { const c2 = this.config?.cooldowns ?? {}; const c = { thread: c2.threads || 0, reply: c2.replies || 0, image: c2.images || 0, thread_global: 300 }; if (d.cookie.indexOf('pass_enabled=1') >= 0) { for (var key of ['reply', 'image']) { c[key] = Math.ceil(c[key] / 2); } } return c; } } const PostRedirect = { init() { return $.on(d, 'QRPostSuccessful', e => { if (!e.detail.redirect) { return; } this.event = e; this.delays = 0; return $.queueTask(() => { if ((e === this.event) && (this.delays === 0)) { return location.href = e.detail.redirect; } }); }); }, delays: 0, event: null, delay() { if (!this.event) { return null; } const e = this.event; this.delays++; return () => { if (e !== this.event) { return; } this.delays--; if (this.delays === 0) { return location.href = e.detail.redirect; } }; } }; class RandomAccessList { constructor(items) { this.length = 0; if (items) { for (var item of items) { this.push(item); } } } push(data) { let item; let { ID } = data; if (!ID) { ID = data.id; } if (this[ID]) { return; } const { last } = this; this[ID] = (item = { prev: last, next: null, data, ID }); item.prev = last; this.last = last ? (last.next = item) : (this.first = item); return this.length++; } before(root, item) { if ((item.next === root) || (item === root)) { return; } this.rmi(item); const { prev } = root; root.prev = item; item.next = root; item.prev = prev; if (prev) { return prev.next = item; } else { return this.first = item; } } after(root, item) { if ((item.prev === root) || (item === root)) { return; } this.rmi(item); const { next } = root; root.next = item; item.prev = root; item.next = next; if (next) { return next.prev = item; } else { return this.last = item; } } prepend(item) { const { first } = this; if ((item === first) || !this[item.ID]) { return; } this.rmi(item); item.next = first; if (first) { first.prev = item; } else { this.last = item; } this.first = item; return delete item.prev; } shift() { return this.rm(this.first.ID); } order() { let item; const order = [(item = this.first)]; while ((item = item.next)) { order.push(item); } return order; } rm(ID) { const item = this[ID]; if (!item) { return; } delete this[ID]; this.length--; this.rmi(item); delete item.next; return delete item.prev; } rmi(item) { const { prev, next } = item; if (prev) { prev.next = next; } else { this.first = next; } if (next) { return next.prev = prev; } else { return this.last = prev; } } } var Unread = { thread: null, title: null, lastReadPost: null, db: null, readCount: 0, posts: null, postsQuotingYou: null, order: null, position: null, hr: null, linePosition: null, init() { if ((g.VIEW !== 'thread') || (!Conf['Unread Count'] && !Conf['Unread Favicon'] && !Conf['Unread Line'] && !Conf['Remember Last Read Post'] && !Conf['Desktop Notifications'] && !Conf['Quote Threading'] && !Conf['Beep'] && !Conf['Beep Quoting You'])) { return; } if (Conf['Remember Last Read Post']) { $.sync('Remember Last Read Post', enabled => Conf['Remember Last Read Post'] = enabled); this.db = new DataBoard('lastReadPosts', this.sync); } this.hr = $.el('hr', { id: 'unread-line', className: 'unread-line' }); this.posts = new Set(); this.postsQuotingYou = new Set(); this.order = new RandomAccessList(); this.position = null; Callbacks.Thread.push({ name: 'Unread', cb: this.node }); return Callbacks.Post.push({ name: 'Unread', cb: this.addPost }); }, node() { Unread.thread = this; Unread.title = d.title; Unread.lastReadPost = Unread.db?.get({ boardID: this.board.ID, threadID: this.ID }) || 0; Unread.readCount = 0; for (var ID of this.posts.keys) { if (+ID <= Unread.lastReadPost) { Unread.readCount++; } } $.one(d, '4chanXInitFinished', Unread.ready); $.on(d, 'PostsInserted', Unread.onUpdate); $.on(d, 'ThreadUpdate', function (e) { if (e.detail[404]) { return Unread.update(); } }); const resetLink = $.el('a', { href: 'javascript:;', className: 'unread-reset', textContent: 'Mark all unread' }); $.on(resetLink, 'click', Unread.reset); return Header.menu.addEntry({ el: resetLink, order: 70 }); }, ready() { if (Conf['Remember Last Read Post'] && Conf['Scroll to Last Read Post']) { Unread.scroll(); } Unread.setLine(true); Unread.read(); Unread.update(); $.on(d, 'scroll visibilitychange', Unread.read); if (Conf['Unread Line']) { return $.on(d, 'visibilitychange', Unread.setLine); } }, positionPrev() { if (Unread.position) { return Unread.position.prev; } else { return Unread.order.last; } }, scroll() { let hash; if ((hash = location.hash.match(/\d+/)) && hash[0] in Unread.thread.posts) { return; } let position = Unread.positionPrev(); while (position) { var { bottom } = position.data.nodes; if (!bottom.getBoundingClientRect().height) { position = position.prev; } else { Header.scrollToIfNeeded(bottom, true); break; } } }, reset() { if (Unread.lastReadPost == null) { return; } Unread.posts = new Set(); Unread.postsQuotingYou = new Set(); Unread.order = new RandomAccessList(); Unread.position = null; Unread.lastReadPost = 0; Unread.readCount = 0; Unread.thread.posts.forEach((post) => Unread.addPost.call(post)); $.forceSync('Remember Last Read Post'); if (Conf['Remember Last Read Post'] && (!Unread.thread.isDead || Unread.thread.isArchived)) { Unread.db.set({ boardID: Unread.thread.board.ID, threadID: Unread.thread.ID, val: 0 }); } Unread.updatePosition(); Unread.setLine(); return Unread.update(); }, sync() { if (Unread.lastReadPost == null) { return; } const lastReadPost = Unread.db.get({ boardID: Unread.thread.board.ID, threadID: Unread.thread.ID, defaultValue: 0 }); if (Unread.lastReadPost >= lastReadPost) { return; } Unread.lastReadPost = lastReadPost; const postIDs = Unread.thread.posts.keys; for (let i = Unread.readCount, end = postIDs.length; i < end; i++) { var ID = +postIDs[i]; if (!Unread.thread.posts.get(ID).isFetchedQuote) { if (ID > Unread.lastReadPost) { break; } Unread.posts.delete(ID); Unread.postsQuotingYou.delete(ID); } Unread.readCount++; } Unread.updatePosition(); Unread.setLine(); return Unread.update(); }, addPost() { if (this.isFetchedQuote || this.isClone) return; Unread.order.push(this); if ((this.ID <= Unread.lastReadPost) || this.isHidden || QuoteYou.isYou(this)) return; Unread.posts.add((Unread.posts.last = this.ID)); Unread.addPostQuotingYou(this); return Unread.position != null ? Unread.position : (Unread.position = Unread.order[this.ID]); }, addPostQuotingYou(post) { for (var quotelink of post.nodes.quotelinks) { if (QuoteYou.db?.get(Get.postDataFromLink(quotelink))) { Unread.postsQuotingYou.add((Unread.postsQuotingYou.last = post.ID)); Unread.openNotification(post); return; } } }, openNotification(post, predicate = ' replied to you') { const isQuotingYou = predicate === ' replied to you'; if ((isQuotingYou && Conf['Beep Quoting You']) || (!isQuotingYou && Conf['Beep'])) { $.event('PlayUpdaterSound', { post, predicate }); } if (!Header.areNotificationsEnabled) { return; } const notif = new Notification(`${post.info.nameBlock}${predicate}`, { body: post.commentDisplay(), icon: Favicon.logo }); notif.onclick = function () { Header.scrollToIfNeeded(post.nodes.bottom, true); return window.focus(); }; return notif.onshow = () => setTimeout(() => notif.close(), 7 * SECOND); }, onUpdate() { return $.queueTask(function () { Unread.setLine(); Unread.read(); return Unread.update(); }); }, readSinglePost(post) { const { ID } = post; if (!Unread.posts.has(ID)) { return; } Unread.posts.delete(ID); Unread.postsQuotingYou.delete(ID); Unread.updatePosition(); Unread.saveLastReadPost(); return Unread.update(); }, read: debounce(100, function (e) { if (!Unread.posts.size && (Unread.readCount !== Unread.thread.posts.keys.length)) { Unread.saveLastReadPost(); } if (d.hidden || !Unread.posts.size) { return; } let count = 0; while (Unread.position) { var { ID, data } = Unread.position; var { bottom } = data.nodes; if (!!bottom.getBoundingClientRect().height && (Header.getBottomOf(bottom) <= -1)) { break; } count++; Unread.posts.delete(ID); Unread.postsQuotingYou.delete(ID); Unread.position = Unread.position.next; } if (!count) { return; } Unread.updatePosition(); Unread.saveLastReadPost(); if (e) { return Unread.update(); } }), updatePosition() { while (Unread.position && !Unread.posts.has(Unread.position.ID)) { Unread.position = Unread.position.next; } }, saveLastReadPost: debounce(2 * SECOND, function () { let ID; $.forceSync('Remember Last Read Post'); if (!Conf['Remember Last Read Post'] || !Unread.db) { return; } const postIDs = Unread.thread.posts.keys; for (let i = Unread.readCount, end = postIDs.length; i < end; i++) { ID = +postIDs[i]; if (!Unread.thread.posts.get(ID).isFetchedQuote) { if (Unread.posts.has(ID)) { break; } Unread.lastReadPost = ID; } Unread.readCount++; } if (Unread.thread.isDead && !Unread.thread.isArchived) { return; } return Unread.db.set({ boardID: Unread.thread.board.ID, threadID: Unread.thread.ID, val: Unread.lastReadPost }); }), setLine(force) { if (!Conf['Unread Line']) { return; } if (Unread.hr.hidden || d.hidden || (force === true)) { const oldPosition = Unread.linePosition; if (Unread.linePosition = Unread.positionPrev()) { if (Unread.linePosition !== oldPosition) { let node = Unread.linePosition.data.nodes.bottom; if (node.nextSibling?.tagName === 'BR') { node = node.nextSibling; } $.after(node, Unread.hr); } } else { $.rm(Unread.hr); } } return Unread.hr.hidden = Unread.linePosition === Unread.order.last; }, update() { const count = Unread.posts.size; const countQuotingYou = Unread.postsQuotingYou.size; if (Conf['Unread Count']) { const titleQuotingYou = Conf['Quoted Title'] && countQuotingYou ? '(!) ' : ''; const titleCount = count || !Conf['Hide Unread Count at (0)'] ? `(${count}) ` : ''; const titleDead = Unread.thread.isDead ? Unread.title.replace('-', (Unread.thread.isArchived ? '- Archived -' : '- 404 -')) : Unread.title; d.title = `${titleQuotingYou}${titleCount}${titleDead}`; } Unread.saveThreadWatcherCount(); if (Conf['Unread Favicon'] && (g.SITE.software === 'yotsuba')) { const { isDead } = Unread.thread; return Favicon.set((countQuotingYou ? (isDead ? 'unreadDeadY' : 'unreadY') : count ? (isDead ? 'unreadDead' : 'unread') : (isDead ? 'dead' : 'default'))); } }, saveThreadWatcherCount: debounce(2 * SECOND, function () { $.forceSync('Remember Last Read Post'); if (Conf['Remember Last Read Post'] && (!Unread.thread.isDead || Unread.thread.isArchived)) { let posts; const quotingYou = !Conf['Require OP Quote Link'] && QuoteYou.isYou(Unread.thread.OP) ? Unread.posts : Unread.postsQuotingYou; if (!quotingYou.size) { quotingYou.last = 0; } else if (!quotingYou.has(quotingYou.last)) { quotingYou.last = 0; posts = Unread.thread.posts.keys; for (let i = posts.length - 1; i >= 0; i--) { if (quotingYou.has(+posts[i])) { quotingYou.last = posts[i]; break; } } } return ThreadWatcher.update(g.SITE.ID, Unread.thread.board.ID, Unread.thread.ID, { last: Unread.thread.lastPost, isDead: Unread.thread.isDead, isArchived: Unread.thread.isArchived, unread: Unread.posts.size, quotingYou: (quotingYou.last || 0) }); } }) }; const ScrollMarkers = { container: undefined, thread: undefined, wired: false, ready: false, flashPost: undefined, flashTimer: 0, preview: undefined, scrollbarWidth: undefined, scrollbarDPR: undefined, position() { const pos = Conf['Scrollbar Marker Position']; let resolved; if (pos === 'over-columns') resolved = 'over-columns'; else if (pos === 'scrollbar' || pos === 'overlay' || pos === 'over') resolved = 'over'; else if (pos === 'offset-single') resolved = 'offset-single'; else resolved = 'offset'; if ((resolved === 'over' || resolved === 'over-columns') && !ScrollMarkers.overlayScrollbars()) { resolved = resolved === 'over-columns' ? 'offset' : 'offset-single'; } return resolved; }, overlayScrollbars() { return ScrollMarkers.measureScrollbarWidth() === 0; }, applyPosition() { const pos = ScrollMarkers.position(); $.rmClass(doc, 'scrollbar-markers-offset', 'scrollbar-markers-offset-single', 'scrollbar-markers-over', 'scrollbar-markers-over-columns'); $.addClass(doc, `scrollbar-markers-${pos}`); ScrollMarkers.updateScrollbarMetrics(); }, measureScrollbarWidth() { const dpr = window.devicePixelRatio || 1; if (ScrollMarkers.scrollbarWidth != null && ScrollMarkers.scrollbarDPR === dpr) { return ScrollMarkers.scrollbarWidth; } const outer = $.el('div', { style: 'width:100px;height:100px;overflow:scroll;position:absolute;top:-9999px;visibility:hidden;pointer-events:none', }); $.add(d.body, outer); const width = outer.offsetWidth - outer.clientWidth; $.rm(outer); ScrollMarkers.scrollbarDPR = dpr; return (ScrollMarkers.scrollbarWidth = width); }, updateScrollbarMetrics() { const measured = ScrollMarkers.measureScrollbarWidth(); const offset = measured > 0 ? 0 : 12; d.documentElement.style.setProperty('--xt-scrollbar-offset', `${offset}px`); d.documentElement.style.setProperty('--xt-scroll-marker-gutter', '6px'); d.documentElement.style.setProperty('--xt-scroll-marker-track', '14px'); d.documentElement.style.setProperty('--xt-scrollbar-width', `${measured > 0 ? measured : 12}px`); }, menu: { entry: undefined, init() { if (g.VIEW !== 'thread' && g.VIEW !== 'index' && g.VIEW !== 'archive' && g.VIEW !== 'catalog') return; const el = $.el('span', { textContent: 'Scroll markers' }); el.dataset.nextKey = 'Scrollbar Markers'; const entry = { el, order: 112, subEntries: [], open() { entry.subEntries = ScrollMarkers.menu.buildSubEntries(); return true; }, }; ScrollMarkers.menu.entry = entry; Header.menu.addEntry(entry); }, buildSubEntries() { const enabledLabel = $.el('label', { className: 'entry scroll-marker-enabled-option', title: 'Show colored markers along the right edge of the page.', innerHTML: ' Enabled', }); const enabledBox = $('input', enabledLabel); enabledBox.checked = Conf['Scrollbar Markers']; $.on(enabledLabel, 'click', (e) => e.stopPropagation()); $.on(enabledBox, 'change', () => { const next = enabledBox.checked; Conf['Scrollbar Markers'] = next; $.set('Scrollbar Markers', next); if (!next) ScrollMarkers.hidePreview(); ScrollMarkers.refreshDeferred(); }); const options = [ ['offset-single', 'Beside scrollbar (single)', false], ['offset', 'Beside scrollbar (columns)', false], ['over', 'Over scrollbar (single)', true], ['over-columns', 'Over scrollbar (columns)', true], ]; const current = ScrollMarkers.position(); const overlay = ScrollMarkers.overlayScrollbars(); const entries = options.map(([value, label, needsOverlay]) => { const disabled = needsOverlay && !overlay; const a = $.el('a', { href: 'javascript:;', textContent: `${current === value ? '✓ ' : ' '}${label}`, className: `entry scroll-marker-position-option${disabled ? ' disabled' : ''}`, }); if (disabled) { a.title = 'Requires overlay scrollbars (#overlay-scrollbars flag).'; } else { $.on(a, 'click', (e) => { e.preventDefault(); Conf['Scrollbar Marker Position'] = value; $.set('Scrollbar Marker Position', value); ScrollMarkers.applyPosition(); ScrollMarkers.refreshDeferred(); $.event('CloseMenu'); }); } return { el: a }; }); entries.unshift({ el: enabledLabel }); if (!overlay) { const note = $.el('div', { className: 'scroll-marker-note', textContent: 'Over modes need overlay scrollbars: turn on the #overlay-scrollbars ' + 'flag (chrome/brave/edge://flags/#overlay-scrollbars).', }); entries.push({ el: note }); } else if ($.engine === 'gecko') { const note = $.el('div', { className: 'scroll-marker-note', textContent: 'Firefox and some browsers draw the scrollbar over the markers, so ' + 'hovering to preview can be unreliable. Use a Beside mode if ' + 'hovering is essential to you.', }); entries.push({ el: note }); } const manage = $.el('a', { href: 'javascript:;', textContent: 'Manage styles', className: 'entry scroll-marker-manage-styles', }); $.on(manage, 'click', (e) => { e.preventDefault(); $.event('OpenSettings', 'Styling'); $.event('CloseMenu'); }); entries.push({ el: manage }); const previewLabel = $.el('label', { className: 'entry scroll-marker-hover-preview-option', title: 'Show a post preview when hovering a marker.', innerHTML: ' Show preview', }); const previewBox = $('input', previewLabel); previewBox.checked = Conf['Scrollbar Marker Hover Preview']; $.on(previewLabel, 'click', (e) => e.stopPropagation()); $.on(previewBox, 'change', () => { const next = previewBox.checked; Conf['Scrollbar Marker Hover Preview'] = next; $.set('Scrollbar Marker Hover Preview', next); if (!next) ScrollMarkers.hidePreview(); }); entries.push({ el: previewLabel }); return entries; }, }, init() { ScrollMarkers.menu.init(); if (g.VIEW !== 'thread' && g.VIEW !== 'index' && g.VIEW !== 'archive' && g.VIEW !== 'catalog') return; ScrollMarkers.container = $.el('div', { id: 'scroll-markers' }); ScrollMarkers.container.hidden = true; ScrollMarkers.applyPosition(); for (const key of [ 'Scrollbar Markers', 'Scrollbar Mark Own Posts', 'Scrollbar Mark Quotes You', 'Scrollbar Mark Ghost Posts', 'Scrollbar Mark Unread Line', 'Scrollbar Marker Hover Preview', 'Unread Line', 'stylingSectionScrollbarMarkers', ]) { $.sync(key, (val) => { Conf[key] = val; ScrollMarkers.refreshDeferred(); }); } $.sync('Scrollbar Marker Position', (val) => { Conf['Scrollbar Marker Position'] = val; ScrollMarkers.applyPosition(); ScrollMarkers.refreshDeferred(); }); if (g.VIEW === 'thread' || g.VIEW === 'archive') { Callbacks.Thread.push({ name: 'Scroll Markers', cb: ScrollMarkers.node, }); } else { $.onExists(doc, 'body', () => ScrollMarkers.mount()); } }, node() { ScrollMarkers.thread = this; ScrollMarkers.mount(); }, mount() { if (!ScrollMarkers.container) return; if (ScrollMarkers.container.parentNode) { ScrollMarkers.applyPosition(); ScrollMarkers.refreshDeferred(); return; } ScrollMarkers.applyPosition(); $.add(d.body, ScrollMarkers.container); ScrollMarkers.container.hidden = true; if (!ScrollMarkers.wired) { ScrollMarkers.wired = true; $.on(d, '4chanXInitFinished', ScrollMarkers.markReady); $.on(d, 'PostsInserted', ScrollMarkers.refreshDeferred); $.on(d, 'IndexRefresh', ScrollMarkers.refreshDeferred); $.on(d, 'ThreadUpdate', ScrollMarkers.refreshDeferred); $.on(d, 'RefreshScrollMarkers', ScrollMarkers.refreshDeferred); $.on(window, 'resize', () => { ScrollMarkers.scrollbarWidth = undefined; ScrollMarkers.updateScrollbarMetrics(); ScrollMarkers.refreshDeferred(); }); $.on(window, 'load', ScrollMarkers.markReady); } ScrollMarkers.refreshDeferred(); }, refreshDeferred: debounce(150, () => ScrollMarkers.refresh(), false), markReady() { ScrollMarkers.ready = true; ScrollMarkers.refreshDeferred(); }, collectMarkerItems(docHeight, onTrack, showOwn, showYou, showGhost) { const items = []; let hasYou = false, hasOwn = false, hasGhost = false; if (g.VIEW === 'catalog' || (!showOwn && !showYou && !showGhost)) { return { items, hasYou, hasOwn, hasGhost }; } const source = g.VIEW === 'index' ? g.posts : ScrollMarkers.thread?.posts; source?.forEach((post) => { if (post.isHidden || post.isClone || post.isFetchedQuote) return; const root = post.nodes.root; if (!root) return; const isOwn = showOwn && root.classList.contains('yourPost'); const isYou = showYou && root.classList.contains('quotesYou'); const isGhost = showGhost && root.classList.contains('from-archive'); if (!isOwn && !isYou && !isGhost) return; if (root.offsetParent == null || !root.getClientRects().length) return; const rect = root.getBoundingClientRect(); const topInDoc = rect.top + window.scrollY; const topPct = (topInDoc / docHeight) * 100; const heightPct = Math.max((rect.height / docHeight) * 100, 0.15); const heightStyle = onTrack ? 'height:3px' : `height:${heightPct}%`; if (isYou) { items.push({ post, type: 'you', cls: 'scroll-marker-you', topPct, heightStyle }); hasYou = true; } if (isOwn) { items.push({ post, type: 'own', cls: 'scroll-marker-own', topPct, heightStyle }); hasOwn = true; } if (isGhost) { items.push({ post, type: 'ghost', cls: 'scroll-marker-ghost', topPct, heightStyle }); hasGhost = true; } }); return { items, hasYou, hasOwn, hasGhost }; }, refresh() { const container = ScrollMarkers.container; if (!container?.parentNode) return; if (!Conf['Scrollbar Markers'] || Conf['stylingSectionScrollbarMarkers'] === false || doc.classList.contains('xt-mobile')) { ScrollMarkers.hidePreview(); container.textContent = ''; container.hidden = true; return; } if (!ScrollMarkers.ready) return; ScrollMarkers.hidePreview(); const docHeight = d.documentElement.scrollHeight || d.body.scrollHeight || 0; if (!(docHeight > 0)) return; const frag = $.frag(); const pos = ScrollMarkers.position(); const onTrack = pos !== 'offset'; const isColumnsMode = pos === 'offset' || pos === 'over-columns'; const showOwn = Conf['Scrollbar Mark Own Posts']; const showYou = Conf['Scrollbar Mark Quotes You']; const showGhost = Conf['Scrollbar Mark Ghost Posts']; const showUnread = Conf['Unread Line'] && Conf['Scrollbar Mark Unread Line']; const { items, hasYou, hasOwn, hasGhost } = ScrollMarkers.collectMarkerItems(docHeight, onTrack, showOwn, showYou, showGhost); const slot = {}; let nextSlot = 0; if (hasYou) slot.you = nextSlot++; if (hasOwn) slot.own = nextSlot++; if (hasGhost) slot.ghost = nextSlot++; const slotCount = nextSlot || 1; const slotWidthPct = 100 / slotCount; for (const item of items) { const marker = $.el('div', { className: `scroll-marker ${item.cls}` }); let style = `top:${item.topPct}%;${item.heightStyle}`; if (isColumnsMode) { const s = slot[item.type] ?? 0; style += `;right:${s * slotWidthPct}%;width:${slotWidthPct}%`; } marker.style.cssText = style; ScrollMarkers.bind(marker, item.post); $.add(frag, marker); } if (g.VIEW === 'thread' && showUnread && Unread?.hr?.isConnected && !Unread.hr.hidden) { const rect = Unread.hr.getBoundingClientRect(); const topInDoc = rect.top + window.scrollY; const topPct = (topInDoc / docHeight) * 100; const marker = $.el('div', { className: 'scroll-marker scroll-marker-unread', }); marker.style.cssText = `top:${topPct}%;height:2px`; $.add(frag, marker); } for (const m of Array.from(container.querySelectorAll('.scroll-marker'))) m.remove(); $.add(container, frag); container.hidden = !container.querySelector('.scroll-marker'); }, bind(marker, post) { $.on(marker, 'mouseenter', (e) => { ScrollMarkers.highlightPost(post); ScrollMarkers.showPreview(marker, post, e); }); $.on(marker, 'mousemove', (e) => ScrollMarkers.movePreview(marker, e)); $.on(marker, 'mouseleave', () => { ScrollMarkers.unhighlightPost(post); ScrollMarkers.hidePreview(); }); $.on(marker, 'click', (e) => { if (e.button !== 0) return; e.preventDefault(); ScrollMarkers.hidePreview(); ScrollMarkers.jumpTo(post); }); }, showPreview(marker, post, e) { if (!Conf['Scrollbar Marker Hover Preview']) return; if (!post?.nodes?.root?.isConnected) return; ScrollMarkers.hidePreview(); const preview = $.el('div', { id: 'qp', className: 'dialog', }); const clone = post.addClone(post.context, true); $.rmAll(clone.nodes.root); $.add(clone.nodes.root, clone.nodes.post); $.add(preview, clone.nodes.root); $.add(Header.hover, preview); ScrollMarkers.preview = { el: preview, post, marker }; ScrollMarkers.positionPreview(e); }, movePreview(marker, e) { if (ScrollMarkers.preview?.marker !== marker) return; ScrollMarkers.positionPreview(e); }, positionPreview(e) { const preview = ScrollMarkers.preview?.el; if (!preview) return; const rect = preview.getBoundingClientRect(); const viewportWidth = d.documentElement.clientWidth; const viewportHeight = d.documentElement.clientHeight; const gap = 16; const edge = 8; let left = e.clientX + gap; if (left + rect.width > viewportWidth - edge) { left = Math.max(edge, e.clientX - rect.width - gap); } let top = e.clientY - Math.min(120, rect.height / 2); top = Math.max(edge, Math.min(top, viewportHeight - rect.height - edge)); preview.style.left = `${left}px`; preview.style.top = `${top}px`; preview.style.right = ''; preview.style.bottom = ''; }, hidePreview() { const preview = ScrollMarkers.preview; if (!preview) return; ScrollMarkers.preview = undefined; $.event('PostsRemoved', null, Header.hover); const cloneRoot = preview.el.firstElementChild; const cloneIndex = cloneRoot?.dataset?.clone; if (cloneIndex != null) { preview.post.rmClone(+cloneIndex); } $.rm(preview.el); }, postsForQuoteHighlight(post) { return [post].concat(post.clones || []); }, highlightPost(post) { for (const p of ScrollMarkers.postsForQuoteHighlight(post)) { const el = p.nodes?.post; if (!el?.isConnected || Header.hover?.contains(el)) continue; $.addClass(el, 'qphl'); } }, unhighlightPost(post) { for (const p of ScrollMarkers.postsForQuoteHighlight(post)) { const el = p.nodes?.post; if (el) $.rmClass(el, 'qphl'); } }, jumpTo(post) { const root = post?.nodes?.root; if (!root?.isConnected) return; Header.scrollTo(root); ScrollMarkers.unhighlightPost(post); const el = post.nodes?.post; if (!el) return; $.addClass(el, 'qphl'); if (ScrollMarkers.flashTimer) clearTimeout(ScrollMarkers.flashTimer); ScrollMarkers.flashPost = post; ScrollMarkers.flashTimer = setTimeout(() => { const flashEl = ScrollMarkers.flashPost?.nodes?.post; if (flashEl) $.rmClass(flashEl, 'qphl'); ScrollMarkers.flashPost = undefined; }, 1500); }, markScroll() { ScrollMarkers.refreshDeferred(); }, }; var QuoteYou = { db: null, mark: null, lastRead: null, init() { if (!Conf['Remember Your Posts']) { return; } this.db = new DataBoard('yourPosts'); $.sync('Remember Your Posts', enabled => Conf['Remember Your Posts'] = enabled); $.on(d, 'QRPostSuccessful', function (e) { const cb = PostRedirect.delay(); return $.get('Remember Your Posts', Conf['Remember Your Posts'], function (items) { if (!items['Remember Your Posts']) { return; } const { boardID, threadID, postID } = e.detail; return QuoteYou.db.set({ boardID, threadID, postID, val: true }, cb); }); }); if (g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive') { return; } if ((Conf['Enable Thread Highlights'] !== false) && Conf['Highlight Own Posts']) { $.addClass(doc, 'highlight-own'); } if ((Conf['Enable Thread Highlights'] !== false) && Conf['Highlight Posts Quoting You']) { $.addClass(doc, 'highlight-you'); } if ((Conf['Enable Thread Highlights'] !== false) && Conf['Highlight Ghost Posts']) { $.addClass(doc, 'highlight-ghost'); } if (Conf['Comment Expansion']) { ExpandComment.callbacks.push(this.node); } this.mark = $.el('span', { textContent: '\u00A0(You)', className: 'qmark-you' }); Callbacks.Post.push({ name: 'Mark Quotes of You', cb: this.node }); QuoteYou.menu.init(); }, isYou(post) { return !!QuoteYou.db?.get({ boardID: post.boardID, threadID: post.threadID, postID: post.ID }); }, node() { if (this.isClone) { return; } if (QuoteYou.isYou(this)) { $.addClass(this.nodes.root, 'yourPost'); ScrollMarkers.markScroll(); } if (!this.quotes.length) { return; } for (var quotelink of this.nodes.quotelinks) { if (QuoteYou.db.get(Get.postDataFromLink(quotelink))) { if (Conf['Mark Quotes of You']) { $.add(quotelink, QuoteYou.mark.cloneNode(true)); } $.addClass(quotelink, 'you'); $.addClass(this.nodes.root, 'quotesYou'); } } }, menu: { post: null, init() { const label = $.el('label', { className: 'toggle-you' }, { innerHTML: ' You' }); const input = $('input', label); $.on(input, 'change', QuoteYou.menu.toggle); Menu.menu?.addEntry({ el: label, order: 80, open(post) { QuoteYou.menu.post = (post.origin || post); input.checked = QuoteYou.isYou(post); return true; } }); }, toggle() { const { post } = QuoteYou.menu; const data = { boardID: post.board.ID, threadID: post.thread.ID, postID: post.ID, val: true }; if (this.checked) { QuoteYou.db.set(data); } else { QuoteYou.db.delete(data); } for (var clone of [post].concat(post.clones)) { clone.nodes.root.classList.toggle('yourPost', this.checked); } for (var quotelink of Get.allQuotelinksLinkingTo(post)) { if (this.checked) { if (Conf['Mark Quotes of You']) { $.add(quotelink, QuoteYou.mark.cloneNode(true)); } } else { $.rm($('.qmark-you', quotelink)); } quotelink.classList.toggle('you', this.checked); if ($.hasClass(quotelink, 'quotelink')) { const quoterPost = Get.postFromNode(quotelink); if (!quoterPost) { continue; } var quoter = quoterPost.nodes.root; quoter.classList.toggle('quotesYou', !!$('.quotelink.you', quoter)); } } ScrollMarkers.markScroll(); } }, cb: { seek(type) { let highlighted, post; let result; const { highlight } = g.SITE.classes; if (highlighted = $(`.${highlight}`)) { $.rmClass(highlighted, highlight); } if (!QuoteYou.lastRead || !doc.contains(QuoteYou.lastRead) || !$.hasClass(QuoteYou.lastRead, 'quotesYou')) { if (!(post = (QuoteYou.lastRead = $('.quotesYou')))) { new Notice('warning', 'No posts are currently quoting you, loser.', 20); return; } if (QuoteYou.cb.scroll(post)) { return; } } else { post = QuoteYou.lastRead; } const str = `${type}::div[contains(@class,'quotesYou')]`; while (post = (result = $.X(str, post)).snapshotItem(type === 'preceding' ? result.snapshotLength - 1 : 0)) { if (QuoteYou.cb.scroll(post)) { return; } } const posts = $$('.quotesYou'); return QuoteYou.cb.scroll(posts[type === 'following' ? 0 : posts.length - 1]); }, scroll(root) { const post = Get.postFromRoot(root); if (!post || !post.nodes.post.getBoundingClientRect().height) { return false; } else { QuoteYou.lastRead = root; location.href = Get.url('post', post); Header.scrollTo(post.nodes.post); if (post.isReply) { const sel = `${g.SITE.selectors.postContainer}${g.SITE.selectors.highlightable.reply}`; let node = post.nodes.root; if (!node.matches(sel)) { node = $(sel, node); } $.addClass(node, g.SITE.classes.highlight); } return true; } } } }; var ExpandThread = { statuses: dict(), expanded: dict(), init() { if (!((g.VIEW === 'index') && Conf['Thread Expansion'])) { return; } if (Conf['JSON Index']) { $.on(d, 'IndexRefreshInternal', this.onIndexRefresh); } else { Callbacks.Thread.push({ name: 'Expand Thread', cb() { ExpandThread.setButton(this); } }); } }, setButton(thread) { if (!thread.nodes.root) return; const a = $('a.summary', thread.nodes.root); if (!a) return; a.textContent = g.SITE.Build.summaryText('+', ...a.textContent.match(/\d+/g)); a.style.cursor = 'pointer'; $.on(a, 'click', ExpandThread.cbToggle); }, disconnect(refresh) { if ((g.VIEW === 'thread') || !Conf['Thread Expansion']) { return; } for (var threadID in ExpandThread.statuses) { var oldReq; var status = ExpandThread.statuses[threadID]; if (oldReq = status.req) { delete status.req; oldReq.abort(); } delete ExpandThread.statuses[threadID]; } if (!refresh) { ExpandThread.expanded = dict(); $.off(d, 'IndexRefreshInternal', this.onIndexRefresh); } }, onIndexRefresh(e) { ExpandThread.disconnect(true); g.BOARD.threads.forEach(thread => ExpandThread.setButton(thread)); const threadIDs = e?.detail?.threadIDs; if (!threadIDs) { return; } for (var fullID of threadIDs) { var thread = g.BOARD.threads.get(+(`${fullID}`.split('.').pop() || 0)); if (!thread || !thread.nodes.root || !ExpandThread.expanded[thread.ID]) { continue; } if (thread.ID in ExpandThread.statuses) { continue; } var a = $('a.summary', thread.nodes.root); if (a) { ExpandThread.expand(thread, a); } } }, cbToggle(e) { if ($.modifiedClick(e)) { return; } e.preventDefault(); ExpandThread.toggle(Get.threadFromNode(this)); }, cbToggleBottom(e) { if ($.modifiedClick(e)) { return; } e.preventDefault(); const thread = Get.threadFromNode(this); if (!thread) { return; } $.rm(this); const { bottom } = thread.nodes.root.getBoundingClientRect(); ExpandThread.toggle(thread); return window.scrollBy(0, (thread.nodes.root.getBoundingClientRect().bottom - bottom)); }, toggle(thread) { if (!thread?.nodes.root) return; const a = $('a.summary', thread.nodes.root); if (!a) return; if (thread.ID in ExpandThread.statuses) { ExpandThread.contract(thread, a, thread.nodes.root); } else { ExpandThread.expand(thread, a); } }, expand(thread, a) { let status; ExpandThread.expanded[thread.ID] = true; ExpandThread.statuses[thread] = (status = {}); a.textContent = g.SITE.Build.summaryText('...', ...a.textContent.match(/\d+/g)); status.req = $.cache(g.SITE.urls.threadJSON({ boardID: thread.board.ID, threadID: thread.ID }), function () { if (this !== status.req) { return; } delete status.req; ExpandThread.parse(this, thread, a); }); status.numReplies = $$(g.SITE.selectors.replyOriginal, thread.nodes.root).length; }, contract(thread, a, threadRoot) { let oldReq; const status = ExpandThread.statuses[thread]; delete ExpandThread.statuses[thread]; delete ExpandThread.expanded[thread.ID]; if (Index.enabled) { Index.clearExpandedThreadText(thread.ID); } if (oldReq = status.req) { delete status.req; oldReq.abort(); if (a) { a.textContent = g.SITE.Build.summaryText('+', ...a.textContent.match(/\d+/g)); } return; } let replies = $$('.thread > .replyContainer', threadRoot); if (status.numReplies) { replies = replies.slice(0, (-status.numReplies)); } let postsCount = 0; let filesCount = 0; for (var reply of replies) { if (Conf['Quote Inlining']) { var inlined; while ((inlined = $('.inlined', reply))) { inlined.click(); } } postsCount++; const post = Get.postFromRoot(reply); if (post && 'file' in post) { filesCount++; } $.rm(reply); } if (Index.enabled) { $.event('PostsRemoved', null, a.parentNode); } a.textContent = g.SITE.Build.summaryText('+', postsCount, filesCount); $.rm($('.summary-bottom', threadRoot)); }, parse(req, thread, a) { let root; if (![200, 304].includes(req.status)) { a.textContent = req.status ? `Error ${req.statusText} (${req.status})` : 'Connection Error'; return; } g.SITE.Build.spoilerRange[thread.board] = req.response.posts[0].custom_spoiler; const posts = []; const postsRoot = []; let filesCount = 0; for (var postData of req.response.posts) { var post; if (postData.no === thread.ID) { continue; } if ((post = thread.posts.get(postData.no)) && !post.isFetchedQuote) { if ('file' in post) { filesCount++; } ({ root } = post.nodes); postsRoot.push(root); continue; } root = g.SITE.Build.postFromObject(postData, thread.board.ID); post = new Post(root, thread, thread.board); if ('file' in post) { filesCount++; } posts.push(post); postsRoot.push(root); } Main.callbackNodes('Post', posts); $.after(a, postsRoot); $.event('PostsInserted', null, a.parentNode); if (Index.enabled) { Index.setExpandedThreadText(thread.ID, req.response.posts); if (Index.search) { Index.highlightSearch(); $.queueTask(() => { if (Index.enabled && Index.search) { Index.highlightSearch(); } }); } } const postsCount = postsRoot.length; a.textContent = g.SITE.Build.summaryText('-', postsCount, filesCount); if (root) { const a2 = a.cloneNode(true); a2.classList.add('summary-bottom'); $.on(a2, 'click', ExpandThread.cbToggleBottom); $.after(root, a2); } } }; var UnreadIndex = { lastReadPost: dict(), hr: dict(), markReadLink: dict(), enabled: null, db: null, init() { if ((g.VIEW !== 'index') || !Conf['Remember Last Read Post'] || !Conf['Unread Line in Index']) { return; } this.enabled = true; this.db = new DataBoard('lastReadPosts', this.sync); Callbacks.Thread.push({ name: 'Unread Line in Index', cb: this.node }); $.on(d, 'IndexRefreshInternal', this.onIndexRefresh); return $.on(d, 'PostsInserted PostsRemoved', this.onPostsInserted); }, node() { UnreadIndex.lastReadPost[this.fullID] = UnreadIndex.db.get({ boardID: this.board.ID, threadID: this.ID }) || 0; if (!Index.enabled) { return UnreadIndex.update(this); } }, onIndexRefresh(e) { return (() => { const result = []; for (var threadID of e.detail.threadIDs) { var thread = g.threads.get(threadID); if (thread) { result.push(UnreadIndex.update(thread)); } } return result; })(); }, onPostsInserted(e) { if (e.target === Index.root) { return; } const thread = Get.threadFromNode(e.target); if (!thread || (thread.nodes.root !== e.target)) { return; } const wasVisible = !!UnreadIndex.hr[thread.fullID]?.parentNode; UnreadIndex.update(thread); if (Conf['Scroll to Last Read Post'] && (e.type === 'PostsInserted') && !wasVisible && !!UnreadIndex.hr[thread.fullID]?.parentNode) { return Header.scrollToIfNeeded(UnreadIndex.hr[thread.fullID], true); } }, sync() { return g.threads.forEach(function (thread) { const lastReadPost = UnreadIndex.db.get({ boardID: thread.board.ID, threadID: thread.ID }) || 0; if (lastReadPost !== UnreadIndex.lastReadPost[thread.fullID]) { UnreadIndex.lastReadPost[thread.fullID] = lastReadPost; if (thread.nodes.root?.parentNode) { return UnreadIndex.update(thread); } } }); }, update(thread) { let divider; const lastReadPost = UnreadIndex.lastReadPost[thread.fullID]; let repliesShown = 0; let repliesRead = 0; let firstUnread = null; thread.posts.forEach(function (post) { if (post.isReply && thread.nodes.root.contains(post.nodes.root)) { repliesShown++; if (post.ID <= lastReadPost) { return repliesRead++; } else if ((!firstUnread || (post.ID < firstUnread.ID)) && !post.isHidden && !QuoteYou.isYou(post)) { return firstUnread = post; } } }); let hr = UnreadIndex.hr[thread.fullID]; if (firstUnread && (repliesRead || ((lastReadPost === thread.OP.ID) && (!$(g.SITE.selectors.summary, thread.nodes.root) || thread.ID in ExpandThread.statuses)))) { if (!hr) { hr = (UnreadIndex.hr[thread.fullID] = $.el('hr', { className: 'unread-line' })); } $.before(firstUnread.nodes.root, hr); } else { $.rm(hr); } const hasUnread = repliesShown ? firstUnread || !repliesRead : Index.enabled ? thread.lastPost > lastReadPost : thread.OP.ID > lastReadPost; thread.nodes.root.classList.toggle('unread-thread', hasUnread); thread.nodes.root.classList.toggle('read-thread', !!lastReadPost && !hasUnread); if (thread.catalogView?.nodes.root) { thread.catalogView.nodes.root.classList.toggle('unread-thread', hasUnread && !!lastReadPost); thread.catalogView.nodes.root.classList.toggle('read-thread', !!lastReadPost && !hasUnread); } let link = UnreadIndex.markReadLink[thread.fullID]; if (!link) { link = (UnreadIndex.markReadLink[thread.fullID] = $.el('a', { className: 'unread-mark-read brackets-wrap', href: 'javascript:;', textContent: 'Mark Read' })); $.on(link, 'click', UnreadIndex.markRead); } if (divider = $(g.SITE.selectors.threadDivider, thread.nodes.root)) { return $.before(divider, link); } else { return $.add(thread.nodes.root, link); } }, markRead() { const thread = Get.threadFromNode(this); if (!thread) { return; } UnreadIndex.lastReadPost[thread.fullID] = thread.lastPost; UnreadIndex.db.set({ boardID: thread.board.ID, threadID: thread.ID, val: thread.lastPost }); $.rm(UnreadIndex.hr[thread.fullID]); thread.nodes.root.classList.remove('unread-thread'); thread.nodes.root.classList.add('read-thread'); if (thread.catalogView?.nodes.root) { thread.catalogView.nodes.root.classList.remove('unread-thread'); thread.catalogView.nodes.root.classList.add('read-thread'); } return ThreadWatcher.update(g.SITE.ID, thread.board.ID, thread.ID, { last: thread.lastPost, unread: 0, quotingYou: 0 }); } }; const MARK_READ_UNDO_MS = 5000; var ThreadWatcher = { enabled: false, shortcut: null, headerToggler: null, db: null, dbLM: null, dialog: null, status: null, list: null, refreshButton: null, markReadButton: null, markReadUndo: null, undoTimeout: null, menuButton: null, closeButton: null, attachButton: null, viewToggleButton: null, tileMenu: null, tileMenuTarget: null, scrollMore: null, unreaddb: null, unreadEnabled: false, draggingLine: null, hoveredThumbnail: null, thumbnailHover: null, syncing: false, timeout: 0, prefixes: null, _lastAttachedW: null, _qrObs: null, drag: { start(e) { if (ThreadWatcher.sortMode() !== 'manual') { e.preventDefault(); return; } ThreadWatcher.draggingLine = this; this.classList.add('drag'); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ThreadWatcher.keyFromLine(this)); } }, end() { ThreadWatcher.clearDragState(); }, enter() { if (ThreadWatcher.draggingLine && ThreadWatcher.draggingLine !== this) { this.classList.add('over'); } }, leave() { this.classList.remove('over'); }, over(e) { if (ThreadWatcher.draggingLine && ThreadWatcher.draggingLine !== this) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }, drop(e) { if (!ThreadWatcher.draggingLine || ThreadWatcher.draggingLine === this) { return; } e.preventDefault(); this.classList.remove('over'); const { before } = ThreadWatcher.dropPosition(this, e); ThreadWatcher.reorderInDOM(ThreadWatcher.draggingLine, this, before); ThreadWatcher.clearDragState(); } }, init() { let sc; if (!(this.enabled = Conf['Thread Watcher'])) { return; } this.shortcut = (sc = $.el('a', { id: 'watcher-link', title: 'Thread Watcher', href: 'javascript:;', })); Icon.set(this.shortcut, 'eye', 'Watcher'); this.db = new DataBoard('watchedThreads', this.refresh, true); this.dbLM = new DataBoard('watcherLastModified', undefined, true); this.dialog = UI.dialog('thread-watcher', { innerHTML: ThreadWatcherPage }); this.status = $('#watcher-status', this.dialog); this.list = $('#watched-threads', this.dialog); this.scrollMore = $('.watcher-scroll-more', this.dialog); this.refreshButton = $('.refresh', this.dialog); this.markReadButton = $('.mark-read', this.dialog); this.menuButton = $('.menu-button', this.dialog); this.closeButton = $('.move > .close', this.dialog); this.attachButton = $('.attach', this.dialog); this.viewToggleButton = $('.view-toggle', this.dialog); this.unreaddb = Unread.db || UnreadIndex.db || new DataBoard('lastReadPosts'); this.unreadEnabled = Conf['Remember Last Read Post']; Icon.set(this.refreshButton, 'refresh'); Icon.set(this.markReadButton, 'check'); Icon.set(this.menuButton, 'caretDown'); Icon.set(this.closeButton, 'xmark'); if (this.attachButton) { Icon.set(this.attachButton, 'link'); } if (this.scrollMore) { Icon.set(this.scrollMore, 'caretDown'); } ThreadWatcher.applyAttachControlsSetting(Conf['Thread Watcher Attach Controls']); if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { this.viewToggleButton.hidden = false; $.on(this.viewToggleButton, 'click', () => { const next = Conf['Mobile Watcher View'] === 'grid' ? 'list' : 'grid'; Conf['Mobile Watcher View'] = next; $.set('Mobile Watcher View', next); ThreadWatcher.applyWatcherView(); }); $.sync('Mobile Watcher View', (val) => { Conf['Mobile Watcher View'] = val || 'grid'; ThreadWatcher.applyWatcherView(); }); ThreadWatcher.applyWatcherView(); ThreadWatcher.initTilePress(); } $.on(d, 'QRPostSuccessful', this.cb.post); $.on(sc, 'click', this.toggleWatcher); $.on(this.refreshButton, 'click', this.buttonFetchAll); $.on(this.markReadButton, 'click', this.cb.markAllRead); $.on(this.closeButton, 'click', this.toggleWatcher); if (this.attachButton) { $.on(this.attachButton, 'click', this.toggleAttach); } $.on(window, 'resize scroll', () => ThreadWatcher.positionThumbnailHover(ThreadWatcher.hoveredThumbnail)); $.on(this.list, 'scroll', () => { ThreadWatcher.positionThumbnailHover(ThreadWatcher.hoveredThumbnail); ThreadWatcher.updateScrollMore(); }); $.on(this.list, 'mouseenter mouseleave', () => ThreadWatcher.updateScrollMore()); $.on(window, 'resize', () => ThreadWatcher.updateScrollMore()); if (this.scrollMore) { $.on(this.scrollMore, 'click', ThreadWatcher.cb.scrollMore); } this.menu.addHeaderMenuEntry(); $.on(d, 'MobileSheetOpened', (e) => { if (e.detail !== 'watcher' && !ThreadWatcher.dialog.hidden) { ThreadWatcher.toggleWatcher(); } }); $.on(d, 'QRDialogCreation', ThreadWatcher.onQRDialogCreation); $.on(d, '4chanXQRMove', (e) => { if (ThreadWatcher.attached()) { ThreadWatcher._doPositionAttached(!!e.detail?.dragging); } }); $.on(window, 'resize', () => { if (ThreadWatcher.attached()) ThreadWatcher.positionIfAttached(); }); $.on(d, '4chanXDragend', (e) => { if (e.detail?.id === 'thread-watcher') { ThreadWatcher.updateAttachButton(); } }); $.onExists(doc, 'body', this.addDialog); switch (g.VIEW) { case 'index': $.on(d, 'IndexUpdate', this.cb.onIndexUpdate); break; case 'thread': $.on(d, 'ThreadUpdate', this.cb.onThreadRefresh); break; } if (Conf['Fixed Thread Watcher']) { $.addClass(doc, 'fixed-watcher'); } if (!Conf['Persistent Thread Watcher'] || resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { $.addClass(ThreadWatcher.shortcut, 'disabled'); this.dialog.hidden = true; } Header.addShortcut('watcher', sc, 510); if ((g.VIEW === 'thread') && (g.SITE.software === 'yotsuba') && resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { const heart = (this.headerToggler = $.el('a', { href: 'javascript:;', className: 'watch-thread-shortcut' })); Icon.set(heart, 'heart', 'Watch Thread'); ThreadWatcher.setToggler(heart, !!this.db.get({ boardID: g.BOARD.ID, threadID: g.THREADID })); $.on(heart, 'click', (e) => { e.preventDefault(); ThreadWatcher.toggle(g.threads.get(`${g.BOARD}.${g.THREADID}`), true); }); Header.addShortcut('watch-thread', heart, 890); } ThreadWatcher.initLastModified(); ThreadWatcher.fetchAuto(); $.on(window, 'visibilitychange focus', () => $.queueTask(ThreadWatcher.fetchAuto)); $.sync('Thread Watcher Attached', (val) => { const attached = ThreadWatcher.attachControlsEnabled() && !!val; Conf['Thread Watcher Attached'] = attached; if (val && !attached) { $.set('Thread Watcher Attached', false); } ThreadWatcher.updateAttachButton(); if (attached) { ThreadWatcher.positionIfAttached(true); } else if (ThreadWatcher.dialog) { ThreadWatcher.restorePosition(); } }); $.sync('Thread Watcher Attach Controls', (val) => { ThreadWatcher.applyAttachControlsSetting(val); ThreadWatcher.menu?.updateAttachLocationChecks?.(); }); $.sync('Thread Watcher Attach Location', (val) => { Conf['Thread Watcher Attach Location'] = val; ThreadWatcher.menu?.updateAttachLocationChecks?.(); if (ThreadWatcher.attached()) { ThreadWatcher._lastAttachedW = null; ThreadWatcher._doPositionAttached(); } }); if (Conf['Menu'] && (Index.enabled || resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice()))) { Menu.menu.addEntry({ el: $.el('a', { href: 'javascript:;', className: 'has-shortcut-text' }, { innerHTML: '' }), order: 9, open({ thread }) { const mobile = doc.classList.contains('xt-mobile'); if (!mobile && Conf['Index Mode'] !== 'catalog') { return false; } this.el.firstElementChild.textContent = ThreadWatcher.isWatched(thread) ? (mobile ? 'Unwatch Thread' : 'Unwatch') : (mobile ? 'Watch Thread' : 'Watch'); const mods = Conf['Watch (catalog click)']; this.el.lastElementChild.textContent = (mods && !mobile) ? `${mods}+click` : ''; if (this.cb) { $.off(this.el, 'click', this.cb); } this.cb = function () { $.event('CloseMenu'); return ThreadWatcher.toggle(thread, true); }; $.on(this.el, 'click', this.cb); return true; } }); } if (g.VIEW !== 'index' && g.VIEW !== 'thread') { return; } Callbacks.Post.push({ name: 'Thread Watcher', cb: this.node }); return Callbacks.CatalogThread.push({ name: 'Thread Watcher', cb: this.catalogNode }); }, isWatched(thread) { return !!ThreadWatcher.db?.get({ boardID: thread.board.ID, threadID: thread.ID }); }, isWatchedRaw(boardID, threadID) { return !!ThreadWatcher.db?.get({ boardID, threadID }); }, setToggler(toggler, isWatched) { toggler.classList.toggle('watched', isWatched); return toggler.title = `${isWatched ? 'Unwatch' : 'Watch'} Thread`; }, node() { let toggler; if (this.isReply) { return; } if (this.isClone) { toggler = $('.watch-thread-link', this.nodes.info); } else { toggler = $.el('button', { type: 'button', className: 'watch-thread-link' }); Icon.set(toggler, 'heart'); $.before($('input', this.nodes.info), toggler); } const siteID = g.SITE.ID; const boardID = this.board.ID; const threadID = this.thread.ID; const data = ThreadWatcher.db.get({ siteID, boardID, threadID }); ThreadWatcher.setToggler(toggler, !!data); $.on(toggler, 'click', ThreadWatcher.cb.toggle); if (data && (data.excerpt == null)) { return $.queueTask(() => { return ThreadWatcher.update(siteID, boardID, threadID, { excerpt: Get.threadExcerpt(this.thread) }); }); } }, catalogNode() { if (ThreadWatcher.isWatched(this.thread)) { $.addClass(this.nodes.root, 'watched'); } return $.on(this.nodes.root, 'mousedown click', e => { if (e.button !== 0) return; const wanted = Conf['Watch (catalog click)']; if (!wanted) return; const got = []; if (e.altKey) { got.push('Alt'); } if (e.ctrlKey) { got.push('Ctrl'); } if (e.metaKey) { got.push('Meta'); } if (e.shiftKey) { got.push('Shift'); } if (got.join('+') !== wanted) return; if (e.type === 'click') ThreadWatcher.toggle(this.thread, true); return e.preventDefault(); }); }, addDialog() { if (!Main.isThisPageLegit()) { return; } ThreadWatcher.applyLayout(); ThreadWatcher.build(); ThreadWatcher.updateAttachButton(); ThreadWatcher._lastAttachedW = null; if (ThreadWatcher.attached()) { ThreadWatcher.positionIfAttached(true); requestAnimationFrame(() => ThreadWatcher.positionIfAttached(true)); setTimeout(() => ThreadWatcher.positionIfAttached(true), 100); } if (QR.nodes?.el) { ThreadWatcher.onQRDialogCreation(); } $.prepend(d.body, ThreadWatcher.dialog); return requestAnimationFrame(() => ThreadWatcher.updateScrollMore()); }, toggleWatcher() { $.toggleClass(ThreadWatcher.shortcut, 'disabled'); const hidden = (ThreadWatcher.dialog.hidden = !ThreadWatcher.dialog.hidden); if (hidden) { ThreadWatcher.hideThumbnailHover(); } else { ThreadWatcher.updateScrollMore(); if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { $.event('MobileSheetOpened', 'watcher'); } } return hidden; }, cb: { openAll() { if ($.hasClass(this, 'disabled')) return; for (var a of $$('a.watcher-link', ThreadWatcher.list)) { $.open(a.href); } $.event('CloseMenu'); }, openUnread() { if ($.hasClass(this, 'disabled')) return; for (var a of $$('.replies-unread > a.watcher-link', ThreadWatcher.list)) { $.open(a.href); } $.event('CloseMenu'); }, openDeads() { if ($.hasClass(this, 'disabled')) return; for (var a of $$('.dead-thread.replies-unread > a.watcher-link', ThreadWatcher.list)) { $.open(a.href); } $.event('CloseMenu'); }, clear() { if (!confirm("Delete ALL threads from watcher?")) return; const ref = ThreadWatcher.getAll(); for (let i = 0, len = ref.length; i < len; i++) { const { siteID, boardID, threadID } = ref[i]; ThreadWatcher.db.delete({ siteID, boardID, threadID }); } ThreadWatcher.refresh(true); $.event('CloseMenu'); }, pruneDeads() { if ($.hasClass(this, 'disabled')) return; for (var { siteID, boardID, threadID, data } of ThreadWatcher.getAll()) { if (data.isDead) { ThreadWatcher.db.delete({ siteID, boardID, threadID }); } } ThreadWatcher.refresh(true); $.event('CloseMenu'); }, pruneReadDeads() { if ($.hasClass(this, 'disabled')) return; for (var { siteID, boardID, threadID, data } of ThreadWatcher.getAll()) { if (data.isDead && !data.unread) { ThreadWatcher.db.delete({ siteID, boardID, threadID }); } } ThreadWatcher.refresh(true); $.event('CloseMenu'); }, dismiss() { for (var { siteID, boardID, threadID, data } of ThreadWatcher.getAll()) { if (data.quotingYou) { ThreadWatcher.update(siteID, boardID, threadID, { dismiss: data.quotingYou || 0, yousCount: 0 }); } } $.event('CloseMenu'); }, markAllRead() { if (ThreadWatcher.markReadUndo) { ThreadWatcher.undoMarkAllRead(); return; } if ($.hasClass(this, 'disabled') || !ThreadWatcher.unreadEnabled) { return; } const snapshot = []; for (var { siteID, boardID, threadID, data } of ThreadWatcher.getAll()) { if (data.unread || data.quotingYou || data.yousCount) { snapshot.push({ siteID, boardID, threadID, prevRead: ThreadWatcher.unreaddb.get({ siteID, boardID, threadID }), unread: data.unread, quotingYou: data.quotingYou, yousCount: data.yousCount, dismiss: data.dismiss }); } if (data.last != null) { ThreadWatcher.unreaddb.set({ siteID, boardID, threadID, val: data.last }); } ThreadWatcher.update(siteID, boardID, threadID, { unread: 0, quotingYou: 0, yousCount: 0, dismiss: data.quotingYou || 0 }); } if (snapshot.length) { ThreadWatcher.showMarkReadUndo(snapshot); } }, undoMarkAllRead() { ThreadWatcher.undoMarkAllRead(); }, expireMarkReadUndo() { ThreadWatcher.expireMarkReadUndo(); }, markRead() { if ($.hasClass(this, 'disabled') || !ThreadWatcher.unreadEnabled) { return; } const line = this.parentNode; if (!line) { return; } const { siteID } = line.dataset; const [boardID, threadID] = line.dataset.fullID.split('.'); ThreadWatcher.markThreadRead(siteID, boardID, +threadID); }, scrollMore() { const { list } = ThreadWatcher; if (!list) { return; } list.scrollBy({ top: Math.max(40, list.clientHeight - 30), behavior: 'smooth' }); }, thumbnailHoverIn() { if (doc.classList.contains('xt-mobile')) { return; } ThreadWatcher.showThumbnailHover(this); }, thumbnailHoverMove() { if (ThreadWatcher.hoveredThumbnail === this) { ThreadWatcher.positionThumbnailHover(this); } }, thumbnailHoverOut() { if (ThreadWatcher.hoveredThumbnail === this) { ThreadWatcher.hideThumbnailHover(); } }, toggle() { const post = Get.postFromNode(this); if (!post) { return; } const { thread } = post; ThreadWatcher.toggle(thread, true); }, rm() { const { siteID } = this.parentNode.dataset; const [boardID, threadID] = this.parentNode.dataset.fullID.split('.'); ThreadWatcher.rm(siteID, boardID, +threadID, undefined, true); }, post(e) { const { boardID, threadID, postID } = e.detail; const cb = PostRedirect.delay(); if (postID === threadID) { if (Conf['Auto Watch']) { ThreadWatcher.addRaw(boardID, threadID, {}, cb, true); } } else if (Conf['Auto Watch Reply']) { ThreadWatcher.add((g.threads.get(boardID + '.' + threadID) || new Thread(threadID, (g.boards[boardID] || new Board(boardID)))), cb, true); } }, onIndexUpdate(e) { const { db } = ThreadWatcher; const siteID = g.SITE.ID; const boardID = g.BOARD.ID; let nKilled = 0; for (var threadID in db.data[siteID].boards[boardID]) { var data = db.data[siteID].boards[boardID][threadID]; if (!data?.isDead && !e.detail.threads.includes(`${boardID}.${threadID}`)) { if (!e.detail.threads.some((fullID) => +fullID.split('.')[1] > threadID)) { continue; } if (ThreadWatcher.shouldAutoPrune(data) || !(data && (typeof data === 'object'))) { db.delete({ boardID, threadID }); nKilled++; } else { ThreadWatcher.fetchStatus({ siteID, boardID, threadID, data }); } } } if (nKilled) { return ThreadWatcher.refresh(); } }, onThreadRefresh(e) { const thread = g.threads.get(e.detail.threadID); if (!e.detail[404] || !ThreadWatcher.isWatched(thread)) { return; } return ThreadWatcher.add(thread); } }, requests: [], fetched: 0, fetch(url, { siteID, force }, args, cb) { if (ThreadWatcher.requests.length === 0) { ThreadWatcher.status.textContent = '...'; $.addClass(ThreadWatcher.refreshButton, 'spin'); } const onloadend = function () { if (this.finished) { return; } this.finished = true; ThreadWatcher.fetched++; if (ThreadWatcher.fetched === ThreadWatcher.requests.length) { ThreadWatcher.clearRequests(); } else { ThreadWatcher.status.textContent = `${Math.round((ThreadWatcher.fetched / ThreadWatcher.requests.length) * 100)}%`; } return cb.apply(this, args); }; const ajax = siteID === g.SITE.ID ? $.ajax : CrossOrigin.ajax; if (force) { delete $.lastModified.ThreadWatcher?.[url]; } const req = $.whenModified(url, 'ThreadWatcher', onloadend, { timeout: MINUTE, ajax }); return ThreadWatcher.requests.push(req); }, clearRequests() { ThreadWatcher.requests = []; ThreadWatcher.fetched = 0; ThreadWatcher.status.textContent = ''; return $.rmClass(ThreadWatcher.refreshButton, 'spin'); }, abort() { delete ThreadWatcher.syncing; for (var req of ThreadWatcher.requests) { if (!req.finished) { req.finished = true; req.abort(); } } return ThreadWatcher.clearRequests(); }, initLastModified() { const lm = ($.lastModified['ThreadWatcher'] || ($.lastModified['ThreadWatcher'] = dict())); for (var siteID in ThreadWatcher.dbLM.data) { var boards = ThreadWatcher.dbLM.data[siteID]; for (var boardID in boards.boards) { var data = boards.boards[boardID]; if (ThreadWatcher.db.get({ siteID, boardID })) { for (var url in data) { var date = data[url]; lm[url] = date; } } else { ThreadWatcher.dbLM.delete({ siteID, boardID }); } } } }, fetchAuto() { let middle; clearTimeout(ThreadWatcher.timeout); if (!Conf['Auto Update Thread Watcher']) { return; } const { db } = ThreadWatcher; const interval = Conf['Show Page'] || (ThreadWatcher.unreadEnabled && Conf['Show Unread Count']) ? 5 * MINUTE : 2 * HOUR; const now = Date.now(); if ((now - interval >= ((middle = db.data.lastChecked || 0)) || middle > now) && !d.hidden && !!d.hasFocus()) { ThreadWatcher.fetchAllStatus(interval); } return ThreadWatcher.timeout = setTimeout(ThreadWatcher.fetchAuto, interval); }, buttonFetchAll() { if (ThreadWatcher.syncing || ThreadWatcher.requests.length) { return ThreadWatcher.abort(); } else { return ThreadWatcher.fetchAllStatus(); } }, fetchAllStatus(interval = 0) { ThreadWatcher.status.textContent = '...'; $.addClass(ThreadWatcher.refreshButton, 'spin'); ThreadWatcher.syncing = true; const dbs = [ThreadWatcher.db, ThreadWatcher.unreaddb, QuoteYou.db].filter(x => x); let n = 0; return dbs.map((dbi) => dbi.forceSync(function () { if ((++n) === dbs.length) { let middle; if (!ThreadWatcher.syncing) { return; } delete ThreadWatcher.syncing; if (0 > (middle = Date.now() - (ThreadWatcher.db.data.lastChecked || 0)) || middle >= interval) { let middle1; const { db } = ThreadWatcher; const now = Date.now(); const deep = !(now - (2 * HOUR) < ((middle1 = db.data.lastChecked2 || 0)) && middle1 <= now); const boards = ThreadWatcher.getAll(true); for (var board of boards) { ThreadWatcher.fetchBoard(board, deep); } db.setLastChecked(); if (deep) { db.setLastChecked('lastChecked2'); } } if (ThreadWatcher.fetched === ThreadWatcher.requests.length) { return ThreadWatcher.clearRequests(); } } })); }, fetchBoard(board, deep) { if (!board.some((thread) => !thread.data.isDead)) { return; } let force = false; for (var thread of board) { var { data } = thread; if (!data.isDead && (data.last !== -1)) { if (Conf['Show Page'] && (data.page == null)) { force = true; } if ((data.modified == null)) { force = (thread.force = true); } if (ThreadWatcher.showThumbnails() && !data.thumbURL) { force = (thread.force = true); } } } const { siteID, boardID } = board[0]; const site = g.sites[siteID]; if (!site) { return; } const urlF = deep && site.threadModTimeIgnoresSage ? 'catalogJSON' : 'threadsListJSON'; const url = site.urls[urlF]?.({ siteID, boardID }); if (!url) { return; } return ThreadWatcher.fetch(url, { siteID, force }, [board, url], ThreadWatcher.parseBoard); }, parseBoard(board, url) { let page, thread; if (this.status !== 200) { return; } const { siteID, boardID } = board[0]; const lmDate = this.getResponseHeader('Last-Modified'); ThreadWatcher.dbLM.extend({ siteID, boardID, val: $.item(url, lmDate) }); const threads = dict(); let pageLength = 0; let nThreads = 0; let oldest = null; try { pageLength = this.response[0]?.threads.length || 0; for (let i = 0; i < this.response.length; i++) { page = this.response[i]; for (var item of page.threads) { threads[item.no] = { page: i + 1, index: nThreads, modified: item.last_modified, replies: item.replies }; nThreads++; if ((oldest == null) || (item.no < oldest)) { oldest = item.no; } } } } catch (error) { for (thread of board) { ThreadWatcher.fetchStatus(thread); } } for (thread of board) { var { threadID, data } = thread; if (threads[threadID]) { var index, modified, replies; ({ page, index, modified, replies } = threads[threadID]); if (Conf['Show Page']) { var lastPage = g.sites[siteID].isPrunedByAge?.({ siteID, boardID }) ? threadID === oldest : index >= (nThreads - pageLength); ThreadWatcher.update(siteID, boardID, threadID, { page, lastPage }); } if (ThreadWatcher.unreadEnabled && Conf['Show Unread Count']) { if ((modified !== data.modified) || ((replies != null) && (replies !== data.replies))) { (thread.newData || (thread.newData = {})).modified = modified; ThreadWatcher.fetchStatus(thread); } } if (ThreadWatcher.showThumbnails() && !data.thumbURL) { ThreadWatcher.fetchStatus(thread); } } else { ThreadWatcher.fetchStatus(thread); } } }, fetchStatus(thread) { const { siteID, boardID, threadID, data, force } = thread; const url = g.sites[siteID]?.urls.threadJSON?.({ siteID, boardID, threadID }); if (!url) { return; } if (data.isDead && !force) { return; } if (data.last === -1) { return; } return ThreadWatcher.fetch(url, { siteID, force }, [thread], ThreadWatcher.parseStatus); }, parseStatus(thread, isArchiveURL) { let isDead, last; let { siteID, boardID, threadID, data, newData, force } = thread; const site = g.sites[siteID]; if ((this.status === 200) && this.response) { let isArchived; last = this.response.posts[this.response.posts.length - 1].no; const replies = this.response.posts.length - 1; isDead = (isArchived = !!(this.response.posts[0].archived || isArchiveURL)); const thumbURL = ThreadWatcher.getOPThumbURL({ siteID, boardID, postObj: this.response.posts[0] }); if (isDead && ThreadWatcher.shouldAutoPrune(data)) { ThreadWatcher.rm(siteID, boardID, threadID); return; } if ((last === data.last) && (isDead === data.isDead) && (isArchived === data.isArchived) && (!thumbURL || thumbURL === data.thumbURL)) { return; } const lastReadPost = ThreadWatcher.unreaddb.get({ siteID, boardID, threadID, defaultValue: 0 }); let unread = data.unread || 0; let quotingYou = data.quotingYou || 0; let yousCount = data.yousCount || 0; const youOP = !!QuoteYou.db?.get({ siteID, boardID, threadID, postID: threadID }); for (var postObj of this.response.posts) { if ((postObj.no <= (data.last || 0)) || (postObj.no <= lastReadPost)) { continue; } if (QuoteYou.db?.get({ siteID, boardID, threadID, postID: postObj.no })) { continue; } var quotesYou = false; if (!Conf['Require OP Quote Link'] && youOP) { quotesYou = true; } else if (QuoteYou.db && postObj.com) { var match; var regexp = site.regexp.quotelinkHTML; regexp.lastIndex = 0; while (match = regexp.exec(postObj.com)) { if (QuoteYou.db.get({ siteID, boardID: match[1] ? encodeURIComponent(match[1]) : boardID, threadID: match[2] || threadID, postID: match[3] || match[2] || threadID })) { quotesYou = true; break; } } } if (!unread || (!quotingYou && quotesYou)) { if (Filter.isHidden(site.Build.parseJSON(postObj, { siteID, boardID }))) { continue; } } unread++; if (quotesYou) { quotingYou = postObj.no; yousCount++; } } if (!newData) { newData = {}; } if ((thumbURL != null) && (thumbURL !== data.thumbURL)) { newData.thumbURL = thumbURL; } $.extend(newData, { last, replies, isDead, isArchived, unread, quotingYou, yousCount }); return ThreadWatcher.update(siteID, boardID, threadID, newData); } else if (this.status === 404) { const archiveURL = g.sites[siteID]?.urls.archivedThreadJSON?.({ siteID, boardID, threadID }); if (!isArchiveURL && archiveURL) { return ThreadWatcher.fetch(archiveURL, { siteID, force }, [thread, true], ThreadWatcher.parseStatus); } else if (site.mayLackJSON && (data.last == null)) { return ThreadWatcher.update(siteID, boardID, threadID, { last: -1 }); } else { return ThreadWatcher.update(siteID, boardID, threadID, { isDead: true }); } } }, getOPThumbURL({ siteID, boardID, thread, postObj }) { if (thread?.OP?.file?.thumbURL) { return thread.OP.file.thumbURL; } if (!postObj) { return; } const site = g.sites[siteID]; if (site?.Build?.parseJSON) { try { const post = site.Build.parseJSON(postObj, { siteID: siteID, boardID: boardID }); if (post?.file?.thumbURL) { return post.file.thumbURL; } } catch (err) { } } if ((postObj.tim != null) && site?.urls?.thumb) { return site.urls.thumb({ siteID, boardID }, `${postObj.tim}s.jpg`); } }, sortComparators: { manual(a, b) { const ao = a.data.order; const bo = b.data.order; if ((ao == null) && (bo == null)) { return 0; } if (ao == null) { return 1; } if (bo == null) { return -1; } return ao - bo; }, 'date-added': (a, b) => (b.data.addedAt || 0) - (a.data.addedAt || 0), 'thread-date': (a, b) => Number(b.threadID) - Number(a.threadID), replies: (a, b) => (b.data.replies || 0) - (a.data.replies || 0), unread: (a, b) => (b.data.unread || 0) - (a.data.unread || 0), activity: (a, b) => (b.data.modified || 0) - (a.data.modified || 0), yous: (a, b) => ThreadWatcher.activeYous(b.data) - ThreadWatcher.activeYous(a.data), board(a, b) { const sa = `${a.siteID}/${a.boardID}`; const sb = `${b.siteID}/${b.boardID}`; return sa < sb ? -1 : sa > sb ? 1 : 0; } }, secondarySortablePrimaries: ['yous', 'unread', 'replies', 'board', 'activity'], sortMode() { const mode = Conf['Thread Watcher Sort'] || 'manual'; return ThreadWatcher.sortComparators[mode] ? mode : 'manual'; }, sortMode2() { const primary = ThreadWatcher.sortMode(); if (!ThreadWatcher.secondarySortablePrimaries.includes(primary)) { return ''; } const mode = Conf['Thread Watcher Sort 2']; if (!mode || mode === 'manual' || mode === primary) { return ''; } return ThreadWatcher.sortComparators[mode] ? mode : ''; }, sortComparator() { const cmp = ThreadWatcher.sortComparators; const primary = cmp[ThreadWatcher.sortMode()]; const sec = ThreadWatcher.sortMode2(); const secondary = sec ? cmp[sec] : null; const tiebreak = cmp['date-added']; return (a, b) => primary(a, b) || (secondary ? secondary(a, b) : 0) || tiebreak(a, b); }, activeYous(data) { if (!data) { return 0; } if ((data.quotingYou || 0) <= (data.dismiss || 0)) { return 0; } return data.yousCount || 1; }, shouldAutoPrune(data) { if (Conf['Auto Prune']) { return true; } if (Conf['Auto Prune Read Only']) { return !data?.unread; } return false; }, linkPruneExclusive(inputs) { const all = inputs.find(i => i?.name === 'Auto Prune'); const read = inputs.find(i => i?.name === 'Auto Prune Read Only'); if (!all || !read) { return; } const link = (a, b) => { $.on(a, 'change', function () { if (!this.checked || !b.checked) { return; } b.checked = false; Conf[b.name] = false; $.set(b.name, false); const wrapper = b.parentNode?.parentNode; if (wrapper?.dataset) { wrapper.dataset.checked = 'false'; } }); }; link(all, read); link(read, all); }, getAll(groupByBoard, _unused) { const all = []; for (var siteID in ThreadWatcher.db.data) { var boards = ThreadWatcher.db.data[siteID]; for (var boardID in boards.boards) { var cont = []; var threads = boards.boards[boardID]; if (Conf['Current Board'] && ((siteID !== g.SITE.ID) || (boardID !== g.BOARD.ID))) { continue; } if (groupByBoard) { all.push((cont = [])); } for (var threadID in threads) { var data = threads[threadID]; if (data && (typeof data === 'object')) { (groupByBoard ? cont : all).push({ siteID, boardID, threadID, data }); } } } } if (!groupByBoard) { all.sort(ThreadWatcher.sortComparator()); } return all; }, makeLine(siteID, boardID, threadID, data) { let page; const x = $.el('a', { textContent: '✕', href: 'javascript:;', draggable: false }); Icon.set(x, 'xmark'); $.on(x, 'click', ThreadWatcher.cb.rm); let { excerpt, isArchived } = data; if (!excerpt) { excerpt = `/${boardID}/ - No.${threadID}`; } if (Conf['Show Site Prefix']) { excerpt = ThreadWatcher.prefixes[siteID] + excerpt; } const link = $.el('a', { href: g.sites[siteID]?.urls.thread({ siteID, boardID, threadID }, isArchived) || '', title: excerpt, className: 'watcher-link', draggable: false }); if (ThreadWatcher.showThumbnails()) { const thumb = data.thumbURL ? $.el('img', { src: data.thumbURL, alt: '', className: 'watcher-thumb' }) : $.el('span', { className: 'watcher-thumb watcher-thumb-missing' }); if (Conf['Thread Watcher Thumbnail Hover'] && thumb.nodeName === 'IMG') { $.on(thumb, 'mouseover', ThreadWatcher.cb.thumbnailHoverIn); $.on(thumb, 'mousemove', ThreadWatcher.cb.thumbnailHoverMove); $.on(thumb, 'mouseout', ThreadWatcher.cb.thumbnailHoverOut); } $.add(link, thumb); } if (Conf['Show Page'] && (data.page != null)) { page = $.el('span', { textContent: `[${data.page}]`, className: 'watcher-page' }); $.add(link, page); } if (ThreadWatcher.unreadEnabled && Conf['Show Unread Count'] && (data.unread != null)) { const count = $.el('span', { textContent: `(${data.unread})`, className: 'watcher-unread' }); $.add(link, count); } const title = $.el('span', { textContent: excerpt, className: 'watcher-title' }); $.add(link, title); const div = $.el('div', { draggable: ThreadWatcher.sortMode() === 'manual' }); const fullID = `${boardID}.${threadID}`; div.dataset.fullID = fullID; div.dataset.siteID = siteID; if ((g.VIEW === 'thread') && (fullID === `${g.BOARD}.${g.THREADID}`)) { $.addClass(div, 'current'); } if (data.isDead) { $.addClass(div, 'dead-thread'); } if (Conf['Show Page']) { if (data.lastPage) { $.addClass(div, 'last-page'); } if (data.page != null) { div.dataset.page = data.page; } } if (ThreadWatcher.unreadEnabled && Conf['Show Unread Count']) { if (data.unread === 0) { $.addClass(div, 'replies-read'); } if (data.unread) { $.addClass(div, 'replies-unread'); } if ((data.quotingYou || 0) > (data.dismiss || 0)) { $.addClass(div, 'replies-quoting-you'); } } for (var event of ['start', 'end', 'enter', 'leave', 'over']) { $.on(div, `drag${event}`, ThreadWatcher.drag[event]); } $.on(div, 'drop', ThreadWatcher.drag.drop); const nodes = [x, link]; if (Conf['Show Mark Thread Read Icons']) { const markRead = $.el('a', { href: 'javascript:;', className: 'watcher-mark-read', draggable: false, title: ThreadWatcher.unreadEnabled ? 'Mark this watched thread as read' : 'Mark read is unavailable because Remember Last Read Post is disabled.' }); Icon.set(markRead, 'check'); if (!ThreadWatcher.unreadEnabled || (!data.unread && !((data.quotingYou || 0) > (data.dismiss || 0)))) { $.addClass(markRead, 'disabled'); } $.on(markRead, 'mousedown pointerdown', e => e.stopPropagation()); $.on(markRead, 'click', ThreadWatcher.cb.markRead); nodes.push(markRead); } $.add(div, nodes); return div; }, keyFromLine(line) { return `${line.dataset.siteID}/${line.dataset.fullID}`; }, threadKey({ siteID, boardID, threadID }) { return `${siteID}/${boardID}.${threadID}`; }, dropPosition(line, e) { const rect = line.getBoundingClientRect(); return { before: e.clientY < rect.top + (rect.height / 2) }; }, clearDragState() { if (ThreadWatcher.draggingLine) { ThreadWatcher.draggingLine.classList.remove('drag'); } ThreadWatcher.draggingLine = null; for (const line of $$('#watched-threads > div', ThreadWatcher.list)) { line.classList.remove('over'); delete line.dataset.dropBefore; } }, reorderInDOM(sourceLine, targetLine, before) { if (!sourceLine || !targetLine || sourceLine === targetLine) { return; } const list = ThreadWatcher.list; if (!list) { return; } if (before) { list.insertBefore(sourceLine, targetLine); } else { list.insertBefore(sourceLine, targetLine.nextSibling); } ThreadWatcher.persistOrderFromDOM(); ThreadWatcher.refreshIcon(); }, persistOrder(threadsArg) { const threads = threadsArg || ThreadWatcher.getAll(false, true); for (let i = 0; i < threads.length; i++) { const thread = threads[i]; const order = i + 1; if (thread.data.order === order) { continue; } thread.data.order = order; ThreadWatcher.db.extend({ siteID: thread.siteID, boardID: thread.boardID, threadID: +thread.threadID, val: { order } }); } }, persistOrderFromDOM() { const lines = $$('#watched-threads > div', ThreadWatcher.list); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const siteID = line.dataset.siteID; const [boardID, threadID] = line.dataset.fullID.split('.'); const order = i + 1; const data = ThreadWatcher.db.get({ siteID, boardID, threadID }); if (!data || data.order === order) { continue; } data.order = order; ThreadWatcher.db.extend({ siteID, boardID, threadID: +threadID, val: { order } }); } }, setPrefixes(threads) { const prefixes = dict(); for (var { siteID } of threads) { if (siteID in prefixes) { continue; } var len = 0; var prefix = ''; var conflicts = Object.keys(prefixes); while (conflicts.length > 0) { len++; prefix = siteID.slice(0, len); var conflicts2 = []; for (var siteID2 of conflicts) { if (siteID2.slice(0, len) === prefix) { conflicts2.push(siteID2); } else if (prefixes[siteID2].length < len) { prefixes[siteID2] = siteID2.slice(0, len); } } conflicts = conflicts2; } prefixes[siteID] = prefix; } return ThreadWatcher.prefixes = prefixes; }, build() { ThreadWatcher.applyLayout(); ThreadWatcher.hideThumbnailHover(); const nodes = []; const threads = ThreadWatcher.getAll(); ThreadWatcher.setPrefixes(threads); for (var { siteID, boardID, threadID, data } of threads) { var thread; if ((data.excerpt == null) && (siteID === g.SITE.ID) && (thread = g.threads.get(`${boardID}.${threadID}`)) && thread.OP) { ThreadWatcher.db.extend({ boardID, threadID, val: { excerpt: Get.threadExcerpt(thread) } }); } if ((data.thumbURL == null) && (siteID === g.SITE.ID) && (thread = g.threads.get(`${boardID}.${threadID}`)) && thread.OP?.file?.thumbURL) { ThreadWatcher.db.extend({ boardID, threadID, val: { thumbURL: thread.OP.file.thumbURL } }); data.thumbURL = thread.OP.file.thumbURL; } nodes.push(ThreadWatcher.makeLine(siteID, boardID, threadID, data)); } const { list } = ThreadWatcher; $.rmAll(list); $.add(list, nodes); const ret = ThreadWatcher.refreshIcon(); ThreadWatcher.updateScrollMore(); if (ThreadWatcher.attached()) { ThreadWatcher.positionIfAttached(true); } return ret; }, refresh(manual) { ThreadWatcher.build(); g.threads.forEach(function (thread) { const isWatched = ThreadWatcher.isWatched(thread); if (thread.OP) { for (var post of [thread.OP, ...thread.OP.clones]) { var toggler; if (toggler = $('.watch-thread-link', post.nodes.info)) { ThreadWatcher.setToggler(toggler, isWatched); } } } if (thread.catalogView) { return thread.catalogView.nodes.root.classList.toggle('watched', isWatched); } }); if (ThreadWatcher.headerToggler) { ThreadWatcher.setToggler(ThreadWatcher.headerToggler, !!ThreadWatcher.db.get({ boardID: g.BOARD.ID, threadID: g.THREADID })); } if (Conf['Pin Watched Threads']) { return $.event('SortIndex', { deferred: !(manual && Conf['Index Mode'] === 'catalog') }); } }, refreshIcon() { for (var className of ['replies-unread', 'replies-quoting-you']) { ThreadWatcher.shortcut.classList.toggle(className, !!$(`.${className}`, ThreadWatcher.dialog)); } if (ThreadWatcher.markReadButton && !ThreadWatcher.markReadUndo) { const hasUnread = !!$('.replies-unread, .replies-quoting-you', ThreadWatcher.list); ThreadWatcher.markReadButton.classList.toggle('disabled', !ThreadWatcher.unreadEnabled || !hasUnread); ThreadWatcher.markReadButton.title = !ThreadWatcher.unreadEnabled ? 'Mark all read is unavailable because Remember Last Read Post is disabled.' : hasUnread ? 'Mark all watched threads as read' : 'No unread watched threads'; } }, showMarkReadUndo(snapshot) { if (!Conf['Show Undo Button']) { return; } const btn = ThreadWatcher.markReadButton; if (!btn) { return; } ThreadWatcher.hideMarkReadUndo(); ThreadWatcher.markReadUndo = snapshot; btn.classList.remove('disabled'); $.addClass(btn, 'watcher-undo-pending'); btn.title = 'Undo mark all read'; Icon.set(btn, 'undo'); const ring = $.el('span', { className: 'watcher-undo-ring' }); ring.style.setProperty('--undo-duration', `${MARK_READ_UNDO_MS}ms`); ring.innerHTML = ''; $.add(btn, ring); ThreadWatcher.undoTimeout = setTimeout(ThreadWatcher.cb.expireMarkReadUndo, MARK_READ_UNDO_MS); }, expireMarkReadUndo() { ThreadWatcher.hideMarkReadUndo(); }, hideMarkReadUndo() { if (ThreadWatcher.undoTimeout) { clearTimeout(ThreadWatcher.undoTimeout); ThreadWatcher.undoTimeout = null; } const btn = ThreadWatcher.markReadButton; if (btn && $.hasClass(btn, 'watcher-undo-pending')) { $.rmClass(btn, 'watcher-undo-pending'); btn.title = 'Mark all watched threads as read'; Icon.set(btn, 'check'); } ThreadWatcher.markReadUndo = null; ThreadWatcher.refreshIcon(); }, undoMarkAllRead() { const snapshot = ThreadWatcher.markReadUndo; if (!snapshot) { return; } for (const { siteID, boardID, threadID, prevRead, unread, quotingYou, yousCount, dismiss } of snapshot) { if (prevRead == null) { ThreadWatcher.unreaddb.delete({ siteID, boardID, threadID }); } else { ThreadWatcher.unreaddb.set({ siteID, boardID, threadID, val: prevRead }); } ThreadWatcher.update(siteID, boardID, threadID, { unread, quotingYou, yousCount, dismiss }); } ThreadWatcher.hideMarkReadUndo(); }, updateScrollMore() { const { list, scrollMore } = ThreadWatcher; if (!list || !scrollMore) { return; } const hasMore = (list.scrollHeight - list.clientHeight - list.scrollTop) > 2; scrollMore.hidden = !hasMore; let youBelow = false; if (hasMore) { const listBottom = list.getBoundingClientRect().bottom; for (const line of list.children) { if (line.classList.contains('replies-quoting-you') && line.getBoundingClientRect().bottom > listBottom + 1) { youBelow = true; break; } } } scrollMore.classList.toggle('has-you', youBelow); }, ensureThumbnailHover() { if (ThreadWatcher.thumbnailHover) { return ThreadWatcher.thumbnailHover; } const hover = $.el('img', { id: 'tw-ihover', alt: '' }); hover.hidden = true; $.add(Header.hover, hover); return (ThreadWatcher.thumbnailHover = hover); }, hideThumbnailHover() { const hover = ThreadWatcher.thumbnailHover; if (!hover) { return; } hover.hidden = true; hover.removeAttribute('src'); hover.removeAttribute('style'); delete ThreadWatcher.hoveredThumbnail; }, showThumbnailHover(thumb) { if (!Conf['Thread Watcher Thumbnail Hover'] || !thumb?.src) { return; } if (!doc.contains(thumb)) { return; } const hover = ThreadWatcher.ensureThumbnailHover(); hover.hidden = false; hover.src = thumb.src; ThreadWatcher.hoveredThumbnail = thumb; ThreadWatcher.positionThumbnailHover(thumb); if (!hover.complete) { const onLoad = function () { $.off(hover, 'load', onLoad); if (ThreadWatcher.hoveredThumbnail === thumb) { ThreadWatcher.positionThumbnailHover(thumb); } }; $.on(hover, 'load', onLoad); } }, positionThumbnailHover(thumb) { const hover = ThreadWatcher.thumbnailHover; if (!hover || hover.hidden || !thumb || !ThreadWatcher.dialog) { return; } if (!doc.contains(thumb)) { ThreadWatcher.hideThumbnailHover(); return; } const dialogRect = ThreadWatcher.dialog.getBoundingClientRect(); const thumbRect = thumb.getBoundingClientRect(); const gap = 8; const viewportPadding = 8; const availableWidth = dialogRect.left - gap - viewportPadding; if (availableWidth < 80) { ThreadWatcher.hideThumbnailHover(); return; } const targetWidth = Math.max(80, Math.floor((availableWidth * ThreadWatcher.thumbnailPreviewSize()) / 100)); hover.style.width = `${targetWidth}px`; hover.style.maxWidth = `${targetWidth}px`; hover.style.maxHeight = `${Math.max(120, doc.clientHeight - (viewportPadding * 2))}px`; const width = hover.offsetWidth; const height = hover.offsetHeight; let left = dialogRect.left - gap - width; if (left < viewportPadding) { left = viewportPadding; } let top = thumbRect.top + ((thumbRect.height - height) / 2); top = Math.max(viewportPadding, Math.min(doc.clientHeight - height - viewportPadding, top)); hover.style.left = `${left}px`; hover.style.top = `${top}px`; }, showThumbnails() { return Conf['Show OP Thumbnails'] || resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice()); }, markThreadRead(siteID, boardID, threadID) { if (!ThreadWatcher.unreadEnabled) { return; } const data = ThreadWatcher.db?.get({ siteID, boardID, threadID }); if (!data) { return; } if (data.last != null) { ThreadWatcher.unreaddb.set({ siteID, boardID, threadID, val: data.last }); } ThreadWatcher.update(siteID, boardID, threadID, { unread: 0, quotingYou: 0, yousCount: 0, dismiss: data.quotingYou || 0 }); }, applyWatcherView() { const grid = Conf['Mobile Watcher View'] === 'grid'; ThreadWatcher.dialog.classList.toggle('watcher-grid', grid); Icon.set(ThreadWatcher.viewToggleButton, grid ? 'bars' : 'grid'); ThreadWatcher.viewToggleButton.title = grid ? 'Switch to list view' : 'Switch to grid view'; }, initTilePress() { const menu = (this.tileMenu = new UI.Menu('watcher-tile')); const openLink = $.el('a', { textContent: 'Open thread', href: 'javascript:;' }); menu.addEntry({ el: openLink, order: 10, open() { openLink.href = ThreadWatcher.tileMenuTarget?.href || 'javascript:;'; return true; } }); const markRead = $.el('a', { textContent: 'Mark read', href: 'javascript:;' }); $.on(markRead, 'click', () => { const t = ThreadWatcher.tileMenuTarget; if (t) { ThreadWatcher.markThreadRead(t.siteID, t.boardID, +t.threadID); } $.event('CloseMenu'); }); menu.addEntry({ el: markRead, order: 20, open: () => ThreadWatcher.unreadEnabled }); const unwatch = $.el('a', { textContent: 'Unwatch', href: 'javascript:;' }); $.on(unwatch, 'click', () => { const t = ThreadWatcher.tileMenuTarget; if (t) { ThreadWatcher.rm(t.siteID, t.boardID, +t.threadID, undefined, true); } $.event('CloseMenu'); }); menu.addEntry({ el: unwatch, order: 30 }); let timer = null; let pressLine = null; let startX = 0; let startY = 0; let fired = false; let suppressClick = false; const cancel = () => { if (timer) { clearTimeout(timer); timer = null; } pressLine = null; }; $.on(this.list, 'pointerdown', (e) => { fired = false; suppressClick = false; if (e.pointerType !== 'touch' || !doc.classList.contains('xt-mobile')) return; const line = e.target.closest?.('#watched-threads > div'); if (!line?.dataset.fullID) return; pressLine = line; startX = e.clientX; startY = e.clientY; timer = setTimeout(() => { timer = null; const l = pressLine; pressLine = null; if (!l) return; fired = true; suppressClick = true; const { siteID } = l.dataset; const [boardID, threadID] = l.dataset.fullID.split('.'); ThreadWatcher.tileMenuTarget = { siteID, boardID, threadID, href: $('.watcher-link', l)?.href }; menu.toggleAtPoint(e, { x: startX, y: startY }, ThreadWatcher.tileMenuTarget); }, 500); }); $.on(this.list, 'pointermove', (e) => { if (!pressLine || e.pointerType !== 'touch') return; if (Math.abs(e.clientX - startX) > 10 || Math.abs(e.clientY - startY) > 10) { cancel(); } }); $.on(this.list, 'pointerup pointercancel', (e) => { if (e.pointerType === 'touch') cancel(); }); $.on(this.list, 'scroll', cancel); doc.addEventListener('contextmenu', (e) => { if (fired || pressLine) { e.preventDefault(); fired = false; } }); doc.addEventListener('click', (e) => { if (suppressClick) { suppressClick = false; e.preventDefault(); e.stopPropagation(); } }, true); }, thumbnailSize() { let size = parseInt(Conf['Thread Watcher Thumbnail Size'], 10); if (isNaN(size)) { size = 40; } return Math.max(16, Math.min(160, size)); }, thumbnailPreviewSize() { let size = parseInt(Conf['Thread Watcher Thumbnail Preview Size'], 10); if (isNaN(size)) { size = 20; } return Math.max(10, Math.min(20, size)); }, thumbnailPreviewScale() { return (ThreadWatcher.thumbnailPreviewSize() / 10).toFixed(1); }, maxHeight() { let height = parseInt(Conf['Thread Watcher Max Height'], 10); if (isNaN(height)) { height = 210; } return Math.max(120, Math.min(999, height)); }, maxWidth() { let width = parseInt(Conf['Thread Watcher Max Width'], 10); if (isNaN(width)) { width = 250; } return Math.max(120, Math.min(999, width)); }, applyLayout() { if (!ThreadWatcher.dialog) { return; } ThreadWatcher.dialog.style.setProperty('--watcher-thumb-size', `${ThreadWatcher.thumbnailSize()}px`); ThreadWatcher.dialog.style.setProperty('--watcher-max-height', `${ThreadWatcher.maxHeight()}px`); ThreadWatcher.dialog.style.setProperty('--watcher-max-width', `${ThreadWatcher.maxWidth()}px`); if (ThreadWatcher.markReadButton) { ThreadWatcher.markReadButton.hidden = !Conf['Show Mark All Read Icon']; } }, attached() { return ThreadWatcher.attachControlsEnabled() && !!Conf['Thread Watcher Attached']; }, attachControlsEnabled() { return Conf['Thread Watcher Attach Controls'] !== false; }, applyAttachControlsSetting(val) { const enabled = val !== false; Conf['Thread Watcher Attach Controls'] = enabled; if (!enabled && Conf['Thread Watcher Attached']) { Conf['Thread Watcher Attached'] = false; $.set('Thread Watcher Attached', false); ThreadWatcher.restorePosition(); $.event('CloseMenu', null); } ThreadWatcher.updateAttachButton(); }, attachLocation() { const loc = Conf['Thread Watcher Attach Location']; return (loc === 'top' || loc === 'left' || loc === 'right') ? loc : 'bottom'; }, updateAttachButton() { const btn = ThreadWatcher.attachButton; if (!btn) { return; } const controlsEnabled = ThreadWatcher.attachControlsEnabled(); btn.hidden = false; btn.classList.toggle('attach-controls-hidden', !controlsEnabled); if (!controlsEnabled) { btn.classList.remove('attached'); btn.title = ''; btn.setAttribute('aria-hidden', 'true'); btn.tabIndex = -1; return; } btn.removeAttribute('aria-hidden'); btn.removeAttribute('tabindex'); const isAttached = ThreadWatcher.attached(); btn.classList.toggle('attached', isAttached); btn.title = isAttached ? 'Detach from Quick Reply' : 'Attach to Quick Reply'; }, toggleAttach() { if (!ThreadWatcher.attachControlsEnabled()) { return; } const val = !ThreadWatcher.attached(); $.set('Thread Watcher Attached', val); Conf['Thread Watcher Attached'] = val; ThreadWatcher.updateAttachButton(); if (val) { ThreadWatcher.positionIfAttached(true); } else { ThreadWatcher.restorePosition(); } }, _posRaf: null, positionIfAttached(immediate = false) { if (!ThreadWatcher.dialog || !ThreadWatcher.attached()) { return; } const qr = QR?.nodes?.el; if (!qr || qr.hidden) { ThreadWatcher.restorePosition(); return; } if (immediate) { if (ThreadWatcher._posRaf) { cancelAnimationFrame(ThreadWatcher._posRaf); ThreadWatcher._posRaf = null; } ThreadWatcher._doPositionAttached(); return; } if (ThreadWatcher._posRaf) { return; } ThreadWatcher._posRaf = requestAnimationFrame(() => { ThreadWatcher._posRaf = null; ThreadWatcher._doPositionAttached(); }); }, _doPositionAttached(skipPreviewWidthSync = false) { const dialog = ThreadWatcher.dialog; if (!dialog) { return; } const qr = QR?.nodes?.el; if (!qr || qr.hidden) { ThreadWatcher.restorePosition(); return; } if (!dialog.classList.contains('watcher-attached')) { dialog.classList.add('watcher-attached'); } if (dialog.style.position !== 'fixed') { dialog.style.position = 'fixed'; } const qrRect = qr.getBoundingClientRect(); const loc = ThreadWatcher.attachLocation(); let targetW = Math.round(qrRect.width); if (loc === 'left' || loc === 'right') { targetW = ThreadWatcher.maxWidth(); } let sizeChanged = false; if (ThreadWatcher._lastAttachedW !== targetW) { dialog.style.width = `${targetW}px`; ThreadWatcher._lastAttachedW = targetW; sizeChanged = true; } if (loc === 'bottom') { dialog.style.left = `${qrRect.left}px`; dialog.style.top = `${qrRect.bottom}px`; dialog.style.right = ''; dialog.style.bottom = ''; } else if (loc === 'top') { dialog.style.left = `${qrRect.left}px`; dialog.style.bottom = `${d.documentElement.clientHeight - qrRect.top}px`; dialog.style.top = ''; dialog.style.right = ''; } else if (loc === 'left') { dialog.style.top = `${qrRect.top}px`; dialog.style.right = `${d.documentElement.clientWidth - qrRect.left}px`; dialog.style.left = ''; dialog.style.bottom = ''; } else if (loc === 'right') { dialog.style.top = `${qrRect.top}px`; dialog.style.left = `${qrRect.right + 2}px`; dialog.style.right = ''; dialog.style.bottom = ''; } if (sizeChanged) { if (loc === 'left' || loc === 'right') { ThreadWatcher.applyLayout(); } else { ThreadWatcher.applyLayout(); dialog.style.setProperty('--watcher-max-width', `${Math.max(120, targetW - 12)}px`); } } QR?.repositionFloatingPreview?.(skipPreviewWidthSync); }, restorePosition() { const dialog = ThreadWatcher.dialog; if (!dialog) { return; } dialog.classList.remove('watcher-attached'); ThreadWatcher._lastAttachedW = null; const saved = Conf['thread-watcher.position'] || ''; if (saved) { dialog.style.cssText = saved; } else { dialog.style.cssText = ''; } dialog.style.width = ''; dialog.style.height = ''; dialog.style.position = Conf['Fixed Thread Watcher'] ? 'fixed' : 'absolute'; ThreadWatcher.applyLayout(); QR?.repositionFloatingPreview?.(); }, onQRDialogCreation() { const qr = QR?.nodes?.el; if (!qr) { return; } if (ThreadWatcher._qrObs) { try { ThreadWatcher._qrObs.disconnect(); } catch (e) { } } const schedule = () => { if (ThreadWatcher.attached()) { ThreadWatcher._doPositionAttached(); } }; let ro = null; if (typeof ResizeObserver === 'function') { ro = new ResizeObserver(schedule); ro.observe(qr); } const mo = new MutationObserver(schedule); mo.observe(qr, { attributes: true, attributeFilter: ['hidden', 'class'] }); ThreadWatcher._qrObs = { ro, mo, disconnect() { try { ro?.disconnect(); } catch (e) { } try { mo.disconnect(); } catch (e) { } } }; schedule(); }, update(siteID, boardID, threadID, newData) { let data, key, line, val; if (!(data = ThreadWatcher.db?.get({ siteID, boardID, threadID }))) { return; } if (newData.isDead && ThreadWatcher.shouldAutoPrune({ unread: newData.unread ?? data.unread })) { ThreadWatcher.rm(siteID, boardID, threadID); return; } if (newData.isDead || (newData.last === -1)) { for (key of ['isArchived', 'page', 'lastPage', 'unread', 'quotingyou']) { if (!(key in newData)) { newData[key] = undefined; } } } if ((newData.last != null) && (newData.last < data.last)) { newData.modified = undefined; } let n = 0; for (key in newData) { val = newData[key]; if (data[key] !== val) { n++; } } if (!n) { return; } ThreadWatcher.db.extend({ siteID, boardID, threadID, val: newData }); if (ThreadWatcher.sortMode() !== 'manual') { return ThreadWatcher.refresh(); } if (line = $(`#watched-threads > [data-site-i-d='${siteID}'][data-full-i-d='${boardID}.${threadID}']`, ThreadWatcher.dialog)) { const newLine = ThreadWatcher.makeLine(siteID, boardID, threadID, data); $.replace(line, newLine); return ThreadWatcher.refreshIcon(); } else { return ThreadWatcher.refresh(); } }, set404(boardID, threadID, cb) { let data; if (!(data = ThreadWatcher.db?.get({ boardID, threadID }))) { return cb(); } if (ThreadWatcher.shouldAutoPrune(data)) { ThreadWatcher.db.delete({ boardID, threadID }); return cb(); } if (data.isDead && !((data.isArchived != null) || (data.page != null) || (data.lastPage != null) || (data.unread != null) || (data.quotingYou != null))) { return cb(); } return ThreadWatcher.db.extend({ boardID, threadID, val: { isDead: true, isArchived: undefined, page: undefined, lastPage: undefined, unread: undefined, quotingYou: undefined } }, cb); }, toggle(thread, manual) { if (!thread) { return; } const siteID = g.SITE.ID; const boardID = thread.board.ID; const threadID = thread.ID; if (ThreadWatcher.db.get({ boardID, threadID })) { return ThreadWatcher.rm(siteID, boardID, threadID, undefined, manual); } else { return ThreadWatcher.add(thread, undefined, manual); } }, add(thread, cb, manual) { const data = {}; const siteID = g.SITE.ID; const boardID = thread.board.ID; const threadID = thread.ID; if (thread.isDead) { const existing = ThreadWatcher.db.get({ boardID, threadID }); if (existing && ThreadWatcher.shouldAutoPrune(existing)) { ThreadWatcher.rm(siteID, boardID, threadID, cb); return; } data.isDead = true; } if (thread.OP) { data.excerpt = Get.threadExcerpt(thread); } if (thread.OP?.file?.thumbURL) { data.thumbURL = thread.OP.file.thumbURL; } return ThreadWatcher.addRaw(boardID, threadID, data, cb, manual); }, addRaw(boardID, threadID, data, cb, manual) { const oldData = ThreadWatcher.db.get({ boardID, threadID, defaultValue: dict() }); if (oldData.order == null) { oldData.order = ThreadWatcher.getAll().length; } if (oldData.addedAt == null) { oldData.addedAt = Date.now(); } delete oldData.last; delete oldData.modified; $.extend(oldData, data); ThreadWatcher.db.set({ boardID, threadID, val: oldData }, cb); ThreadWatcher.refresh(manual); const thread = { siteID: g.SITE.ID, boardID, threadID, data, force: true }; if (Conf['Show Page'] && !data.isDead) { return ThreadWatcher.fetchBoard([thread]); } else if (ThreadWatcher.unreadEnabled && Conf['Show Unread Count']) { return ThreadWatcher.fetchStatus(thread); } }, rm(siteID, boardID, threadID, cb, manual) { ThreadWatcher.db.delete({ siteID, boardID, threadID }, cb); return ThreadWatcher.refresh(manual); }, menu: { updateAttachLocationChecks() { }, init() { if (!Conf['Thread Watcher']) { return; } const menu = (this.menu = new UI.Menu('thread watcher')); $.on($('.menu-button', ThreadWatcher.dialog), 'click', function (e) { return menu.toggle(e, this, ThreadWatcher); }); return this.addMenuEntries(); }, addHeaderMenuEntry() { if (g.VIEW !== 'thread') { return; } const entryEl = $.el('a', { href: 'javascript:;' }); Header.menu.addEntry({ el: entryEl, order: 60, open() { const [addClass, rmClass, text] = !!ThreadWatcher.db.get({ boardID: g.BOARD.ID, threadID: g.THREADID }) ? ['unwatch-thread', 'watch-thread', 'Unwatch thread'] : ['watch-thread', 'unwatch-thread', 'Watch thread']; $.addClass(entryEl, addClass); $.rmClass(entryEl, rmClass); entryEl.textContent = text; return true; } }); return $.on(entryEl, 'click', () => ThreadWatcher.toggle(g.threads.get(`${g.BOARD}.${g.THREADID}`), true)); }, addMenuEntries() { const toggleDisabledDead = function () { this.el.classList.toggle('disabled', !$('.dead-thread', ThreadWatcher.list)); return true; }; const entries = [ { text: 'Open all threads', cb: ThreadWatcher.cb.openAll, open() { this.el.classList.toggle('disabled', !ThreadWatcher.list.firstElementChild); return true; } }, { text: 'Clear all threads', cb: ThreadWatcher.cb.clear, open() { this.el.classList.toggle('disabled', !ThreadWatcher.list.firstElementChild); return true; } }, { text: 'Open unread threads', cb: ThreadWatcher.cb.openUnread, open() { this.el.classList.toggle('disabled', !$('.replies-unread', ThreadWatcher.list)); return true; } }, { text: 'Open unread dead threads', cb: ThreadWatcher.cb.openDeads, open: toggleDisabledDead, }, { text: 'Prune all dead threads', cb: ThreadWatcher.cb.pruneDeads, open: toggleDisabledDead, }, { text: 'Prune read dead threads', cb: ThreadWatcher.cb.pruneReadDeads, open: toggleDisabledDead, }, { text: 'Dismiss posts quoting you', title: 'Unhighlight the thread watcher icon and threads until there are new replies quoting you.', cb: ThreadWatcher.cb.dismiss, open() { this.el.classList.toggle('disabled', !$.hasClass(ThreadWatcher.shortcut, 'replies-quoting-you')); return true; } }, { text: 'Max H/W', nextKey: 'Thread Watcher Max Height', open() { this.el.innerHTML = `Max H W`; const [heightInput, widthInput] = $$('input', this.el); for (const input of [heightInput, widthInput]) { $.on(input, 'click', e => e.stopPropagation()); $.on(input, 'mousedown', e => e.stopPropagation()); $.on(input, 'pointerdown', e => e.stopPropagation()); } $.on(heightInput, 'change', function () { let height = parseInt(this.value, 10); if (isNaN(height)) { height = 210; } height = Math.max(120, Math.min(999, height)); this.value = `${height}`; $.set('Thread Watcher Max Height', height); Conf['Thread Watcher Max Height'] = height; ThreadWatcher.applyLayout(); }); $.on(widthInput, 'change', function () { let width = parseInt(this.value, 10); if (isNaN(width)) { width = 250; } width = Math.max(120, Math.min(999, width)); this.value = `${width}`; $.set('Thread Watcher Max Width', width); Conf['Thread Watcher Max Width'] = width; ThreadWatcher.applyLayout(); }); return true; } }, ]; for (var { text, title, cb, open, nextKey } of entries) { var entry = { el: $.el('a', { textContent: text, href: 'javascript:;' }) }; if (title) { entry.el.title = title; } if (nextKey) { entry.el.dataset.nextKey = nextKey; } if (cb) { $.on(entry.el, 'click', cb); } entry.open = open.bind(entry); this.menu.addEntry(entry); } this.addSortEntry(); this.addAttachLocationEntry(); const automationNames = ['Auto Update Thread Watcher', 'Auto Watch', 'Auto Watch Reply', 'Auto Prune', 'Auto Prune Read Only']; const displayNames = ['Show Page', 'Show Unread Count', 'Show Mark All Read Icon', 'Show Mark Thread Read Icons', 'Show Undo Button', 'Show Site Prefix']; const grouped = new Set([...automationNames, ...displayNames, 'Show OP Thumbnails', 'Thread Watcher Thumbnail Hover']); const makeCheckboxes = (names) => names .filter(name => Config.threadWatcher[name]) .map(name => this.makeCheckbox(name, Config.threadWatcher[name][1])); const autoEntries = makeCheckboxes(automationNames); ThreadWatcher.linkPruneExclusive(autoEntries.map(e => e.el.firstElementChild)); this.menu.addEntry({ el: $.el('a', { textContent: 'Auto' }), subEntries: autoEntries }); this.menu.addEntry({ el: $.el('a', { textContent: 'Display' }), subEntries: makeCheckboxes(displayNames) }); for (var name in Config.threadWatcher) { if (grouped.has(name)) { continue; } this.menu.addEntry(this.makeCheckbox(name, Config.threadWatcher[name][1])); } this.addThumbnailControls(); }, makeCheckbox(name, desc) { const labels = { 'Show Mark All Read Icon': 'Mark All Read Icon', 'Show Mark Thread Read Icons': 'Mark Thread Read Icons' }; const label = labels[name] || name; const entry = { type: 'thread watcher', el: UI.checkbox(name, label.replace(' Thread Watcher', '')) }; entry.el.title = desc; const input = entry.el.firstElementChild; if ((name === 'Show Unread Count') && !ThreadWatcher.unreadEnabled) { input.disabled = true; $.addClass(entry.el, 'disabled'); entry.el.title += '\n[Remember Last Read Post is disabled.]'; } $.on(entry.el, 'mousedown', e => e.stopPropagation()); $.on(entry.el, 'click', e => e.stopPropagation()); $.on(input, 'change', $.cb.checked); if (['Current Board', 'Show Page', 'Show Unread Count', 'Show Mark All Read Icon', 'Show Site Prefix', 'Show Mark Thread Read Icons'].includes(name)) $.on(input, 'change', () => ThreadWatcher.refresh()); if (['Show Page', 'Show Unread Count', 'Auto Update Thread Watcher'].includes(name)) $.on(input, 'change', ThreadWatcher.fetchAuto); return entry; }, addSortEntry() { const sortOptions = [ ['manual', 'Manual (drag)'], ['yous', '(You)s'], ['date-added', 'Date added'], ['thread-date', 'Thread date'], ['replies', 'Reply count'], ['unread', 'Unread count'], ['activity', 'Last activity'], ['board', 'Board'], ]; const allOptions = []; const refreshChecks = () => { for (const o of allOptions) { o.updateVisible(); o.updateCheck(); } }; const makeOption = (value, label, isActive, onSelect, visibleFor) => { const el = $.el('a', { href: 'javascript:;', innerHTML: '' }); const check = $('.watcher-sort-check', el); $('.watcher-sort-label', el).textContent = label; const updateCheck = () => { check.textContent = isActive() ? '✓' : ''; }; const updateVisible = () => { el.style.display = (visibleFor && !visibleFor()) ? 'none' : ''; }; $.on(el, 'mousedown', e => e.stopPropagation()); $.on(el, 'click', function (e) { e.stopPropagation(); onSelect(); refreshChecks(); }); const entry = { el, updateCheck, updateVisible, open() { updateVisible(); updateCheck(); return true; } }; allOptions.push(entry); return entry; }; const secondaryShown = () => ThreadWatcher.secondarySortablePrimaries.includes(ThreadWatcher.sortMode()); let updateThenBy = () => { }; const primaryEntries = sortOptions.map(([value, label], i) => { const entry = makeOption(value, label, () => ThreadWatcher.sortMode() === value, () => { $.set('Thread Watcher Sort', value); Conf['Thread Watcher Sort'] = value; ThreadWatcher.refresh(); updateThenBy(); }); entry.order = i + 1; return entry; }); const setSecondary = (value) => () => { $.set('Thread Watcher Sort 2', value); Conf['Thread Watcher Sort 2'] = value; ThreadWatcher.refresh(); }; const secondaryEntries = [ makeOption('none', 'None', () => !ThreadWatcher.sortMode2(), setSecondary('')), ...sortOptions .filter(([value]) => value !== 'manual') .map(([value, label]) => makeOption(value, label, () => ThreadWatcher.sortMode2() === value, setSecondary(value), () => value !== ThreadWatcher.sortMode())) ].map((entry, i) => { entry.order = i + 1; return entry; }); const thenByEl = $.el('a', { href: 'javascript:;', textContent: 'Then by' }); updateThenBy = () => thenByEl.classList.toggle('disabled', !secondaryShown()); const thenByEntry = { el: thenByEl, order: 50, subEntries: secondaryEntries, open() { updateThenBy(); return true; } }; $.addClass(thenByEl, 'watcher-sort-then-by'); this.menu.addEntry({ el: $.el('a', { href: 'javascript:;', textContent: 'Sort' }), order: 50, subEntries: [...primaryEntries, thenByEntry], open() { this.el.dataset.nextKey = 'Thread Watcher Sort'; this.el.classList.toggle('disabled', !ThreadWatcher.list.firstElementChild); return true; } }); }, addAttachLocationEntry() { const locationOptions = [ ['bottom', 'Bottom'], ['top', 'Top'], ['left', 'Left'], ['right', 'Right'], ]; const subEntries = []; this.updateAttachLocationChecks = () => { for (const entry of subEntries) { entry.updateCheck(); } }; const enabledLabel = $.el('label', { className: 'watcher-attach-enabled-option', title: 'Show the attach button and allow attaching the watcher to the Quick Reply.', innerHTML: ' Enable', }); const enabledBox = $('input', enabledLabel); $.on(enabledLabel, 'mousedown', e => e.stopPropagation()); $.on(enabledLabel, 'click', (e) => e.stopPropagation()); $.on(enabledBox, 'change', () => { const next = enabledBox.checked; $.set('Thread Watcher Attach Controls', next); ThreadWatcher.applyAttachControlsSetting(next); for (const entry of subEntries) { entry.updateCheck(); } }); subEntries.push({ el: enabledLabel, updateCheck() { enabledBox.checked = ThreadWatcher.attachControlsEnabled(); }, open() { this.updateCheck(); return true; } }); locationOptions.forEach(([value, label]) => { const el = $.el('a', { href: 'javascript:;', className: 'watcher-attach-location-option', innerHTML: '' }); const check = $('.watcher-sort-check', el); const labelEl = $('.watcher-sort-label', el); labelEl.textContent = label; const updateCheck = () => { check.textContent = ThreadWatcher.attachLocation() === value ? '✓' : ''; el.classList.toggle('disabled', !ThreadWatcher.attachControlsEnabled()); }; $.on(el, 'mousedown', e => e.stopPropagation()); $.on(el, 'click', function (e) { e.stopPropagation(); if (!ThreadWatcher.attachControlsEnabled()) { return; } $.set('Thread Watcher Attach Location', value); Conf['Thread Watcher Attach Location'] = value; if (ThreadWatcher.attached()) { ThreadWatcher._lastAttachedW = null; ThreadWatcher._doPositionAttached(); } for (const entry of subEntries) { entry.updateCheck(); } }); subEntries.push({ el, updateCheck, open() { updateCheck(); return true; } }); }); this.menu.addEntry({ el: $.el('a', { textContent: 'Attach Location' }), order: 51, subEntries, open() { this.el.dataset.nextKey = 'Thread Watcher Attach Location'; this.el.title = 'Where to attach the watcher relative to the Quick Reply when attached.\nBottom/top: width follows the QR. Left/right: width uses the manual Max W; height sizes to content.'; return true; } }); }, addThumbnailControls() { const entry = { type: 'thread watcher', el: $.el('a', { textContent: 'Thumbnails', className: 'watcher-thumbnail-controls' }), open() { this.el.dataset.nextKey = 'Thread Watcher Thumbnail Size'; this.el.innerHTML = `pxx`; const [thumbToggle, previewToggle] = $$('input[type="checkbox"]', this.el); const [sizeInput, previewSizeInput] = $$('input[type="range"]', this.el); const [sizeNumber, previewScaleNumber] = $$('input[type="number"]', this.el); for (const input of [thumbToggle, previewToggle, sizeInput, previewSizeInput, sizeNumber, previewScaleNumber]) { $.on(input, 'click', e => e.stopPropagation()); $.on(input, 'mousedown', e => e.stopPropagation()); $.on(input, 'pointerdown', e => e.stopPropagation()); } $.on(thumbToggle, 'change', function () { $.set('Show OP Thumbnails', this.checked); Conf['Show OP Thumbnails'] = this.checked; if (!this.checked) { ThreadWatcher.hideThumbnailHover(); } if (this.checked) { ThreadWatcher.fetchAllStatus(); } ThreadWatcher.refresh(); }); $.on(previewToggle, 'change', function () { $.set('Thread Watcher Thumbnail Hover', this.checked); Conf['Thread Watcher Thumbnail Hover'] = this.checked; if (!this.checked) { ThreadWatcher.hideThumbnailHover(); } ThreadWatcher.refresh(); }); const updateThumbnailSize = function (value) { let size = parseInt(value, 10); if (isNaN(size)) { size = 40; } size = Math.max(16, Math.min(160, size)); sizeInput.value = `${size}`; sizeNumber.value = `${size}`; $.set('Thread Watcher Thumbnail Size', size); Conf['Thread Watcher Thumbnail Size'] = size; ThreadWatcher.applyLayout(); ThreadWatcher.refresh(); }; $.on(sizeInput, 'input', function () { updateThumbnailSize(this.value); }); $.on(sizeNumber, 'change', function () { updateThumbnailSize(this.value); }); const updatePreviewSize = function (value, isScale = false) { let size = isScale ? Math.round(parseFloat(value) * 10) : parseInt(value, 10); if (isNaN(size)) { size = 20; } size = Math.max(10, Math.min(20, size)); previewSizeInput.value = `${size}`; previewScaleNumber.value = (size / 10).toFixed(1); $.set('Thread Watcher Thumbnail Preview Size', size); Conf['Thread Watcher Thumbnail Preview Size'] = size; ThreadWatcher.positionThumbnailHover(ThreadWatcher.hoveredThumbnail); }; $.on(previewSizeInput, 'input', function () { updatePreviewSize(this.value); }); $.on(previewScaleNumber, 'change', function () { updatePreviewSize(this.value, true); }); return true; } }; entry.open = entry.open.bind(entry); return this.menu.addEntry(entry); } } }; const parseArchivePost = (data) => { let comment = (data.comment || '').split(/(\n|\[\/?(?:b|spoiler|code|moot|banned|fortune(?: color="#\w+")?|i|red|green|blue)\])/); comment = comment.map((text, i) => { if ((i % 2) === 1) { var tag = Fetcher.archiveTags[text.replace(/\ .*\]/, ']')]; return (typeof tag === 'function') ? tag(text) : tag; } else { var greentext = text[0] === '>'; text = text .replace(/(\[\/?[a-z]+):lit(\])/g, '$1$2') .split(/(>>(?:>\/[a-z\d]+\/)?\d+)/g) .map((text2, j) => ((j % 2) ? `${E(text2)}` : E(text2))) .join(''); return { innerHTML: (greentext ? `${text}` : text) }; } }); comment = { innerHTML: E.cat(comment), [isEscaped]: true }; const o = { ID: data.num, threadID: data.thread_num, boardID: data.board.shortname, isReply: data.num !== data.thread_num, fileDeleted: false, info: { subject: data.title, email: data.email, name: data.name || '', tripcode: data.trip, capcode: (() => { switch (data.capcode) { case 'M': return 'Mod'; case 'A': return 'Admin'; case 'D': return 'Developer'; case 'V': return 'Verified'; case 'F': return 'Founder'; case 'G': return 'Manager'; } })(), uniqueID: data.poster_hash, flagCode: data.poster_country, flagCodeTroll: data.troll_country_code, flag: data.poster_country_name || data.troll_country_name, dateUTC: data.timestamp, dateText: data.fourchan_date, commentHTML: comment, }, file: null, extra: null, }; if (o.info.capcode) { delete o.info.uniqueID; } if (data.media && !!+data.media.banned) { o.fileDeleted = true; } else if (data.media?.media_filename) { let { thumb_link } = data.media; let media_link = Redirect.to('file', { boardID: o.boardID, filename: data.media.media_orig }); if (!Redirect.securityCheck(media_link)) { media_link = ''; } if (thumb_link?.[0] === '/' && media_link) { thumb_link = media_link.split('/', 3).join('/') + thumb_link; } if (!Redirect.securityCheck(thumb_link)) { thumb_link = ''; } o.file = { name: data.media.media_filename, url: media_link || (o.boardID === 'f' ? `${location.protocol}//${ImageHost.flashHost()}/${o.boardID}/${encodeURIComponent(E(data.media.media_filename))}` : `${location.protocol}//${ImageHost.host()}/${o.boardID}/${data.media.media_orig}`), height: data.media.media_h, width: data.media.media_w, MD5: data.media.media_hash, size: $.bytesToString(+data.media.media_size), thumbURL: thumb_link || `${location.protocol}//${ImageHost.thumbHost()}/${o.boardID}/${data.media.preview_orig}`, theight: data.media.preview_h, twidth: data.media.preview_w, isSpoiler: data.media.spoiler === '1' }; if (!/\.pdf$/.test(o.file.url)) { o.file.dimensions = `${o.file.width}x${o.file.height}`; } if ((o.boardID === 'f') && data.media.exif) { o.file.tag = JSON.parse(data.media.exif).Tag; } } o.extra = dict(); const board = g.boards[o.boardID] || new Board(o.boardID); const thread = g.threads.get(`${o.boardID}.${o.threadID}`) || new Thread(o.threadID, board); const post = new Post(g.SITE.Build.post(o), thread, board); post.resurrect(); post.markAsFromArchive(); if (post.file) { post.file.thumbURL = o.file.thumbURL; } Main.callbackNodes('Post', [post]); return post; }; var ReplyPruning = { container: null, summary: null, inputs: null, active: false, thread: null, init() { if ((g.VIEW !== 'thread') || !Conf['Reply Pruning']) { return; } this.container = $.frag(); this.summary = $.el('span', { hidden: true, className: 'summary' }); this.summary.style.cursor = 'pointer'; $.on(this.summary, 'click', () => { this.inputs.enabled.checked = !this.inputs.enabled.checked; return $.event('change', null, this.inputs.enabled); }); const label = UI.checkbox('Prune Replies', 'Show Last', Conf['Prune All Threads']); const el = $.el('span', { title: 'Maximum number of replies to show.' }, { innerHTML: " " }); $.prepend(el, label); this.inputs = { enabled: label.firstElementChild, replies: el.lastElementChild }; this.setEnabled.call(this.inputs.enabled); $.on(this.inputs.enabled, 'change', this.setEnabled); $.on(this.inputs.replies, 'change', $.cb.value); Header.menu.addEntry({ el, order: 190 }); return Callbacks.Thread.push({ name: 'Reply Pruning', cb: this.node }); }, position: 0, hidden: 0, hiddenFiles: 0, total: 0, totalFiles: 0, setEnabled() { const other = QuoteThreading.input; if (this.checked && other?.checked) { other.checked = false; $.event('change', null, other); } return ReplyPruning.active = this.checked; }, showIfHidden(id) { if (ReplyPruning.container && $(`#${id}`, ReplyPruning.container)) { ReplyPruning.inputs.enabled.checked = false; return $.event('change', null, ReplyPruning.inputs.enabled); } }, node() { let middle; ReplyPruning.thread = this; if (this.isSticky) { ReplyPruning.active = (ReplyPruning.inputs.enabled.checked = true); if (QuoteThreading.input) { Conf['Thread Quotes'] = (QuoteThreading.input.checked = false); } } this.posts.forEach(function (post) { if (post.isReply) { ReplyPruning.total++; if (post.file) { return ReplyPruning.totalFiles++; } } }); if (ReplyPruning.active && /^#p\d+$/.test(location.hash) && (1 <= (middle = this.posts.keys.indexOf(location.hash.slice(2))) && middle < 1 + Math.max(ReplyPruning.total - +Conf["Max Replies"], 0))) { ReplyPruning.active = (ReplyPruning.inputs.enabled.checked = false); } $.after(this.OP.nodes.root, ReplyPruning.summary); $.on(ReplyPruning.inputs.enabled, 'change', ReplyPruning.update); $.on(ReplyPruning.inputs.replies, 'change', ReplyPruning.update); $.on(d, 'ThreadUpdate', ReplyPruning.updateCount); $.on(d, 'ThreadUpdate', ReplyPruning.update); return ReplyPruning.update(); }, updateCount(e) { if (e.detail[404]) { return; } for (var fullID of e.detail.newPosts) { ReplyPruning.total++; if (g.posts.get(fullID).file) { ReplyPruning.totalFiles++; } } }, update() { let boardTop, node, post; const hidden1 = ReplyPruning.hidden; const hidden2 = ReplyPruning.active ? Math.max(ReplyPruning.total - +Conf["Max Replies"], 0) : 0; const oldPos = d.body.clientHeight - window.scrollY; const { posts } = ReplyPruning.thread; if (ReplyPruning.hidden < hidden2) { while ((ReplyPruning.hidden < hidden2) && (ReplyPruning.position < posts.keys.length)) { post = posts.get(posts.keys[ReplyPruning.position++]); if (post.isReply && !post.isFetchedQuote) { while ((node = ReplyPruning.summary.nextSibling) && (node !== post.nodes.root)) { $.add(ReplyPruning.container, node); } $.add(ReplyPruning.container, post.nodes.root); ReplyPruning.hidden++; if (post.file) { ReplyPruning.hiddenFiles++; } } } } else if (ReplyPruning.hidden > hidden2) { const frag = $.frag(); while ((ReplyPruning.hidden > hidden2) && (ReplyPruning.position > 0)) { post = posts.get(posts.keys[--ReplyPruning.position]); if (post.isReply && !post.isFetchedQuote) { while ((node = ReplyPruning.container.lastChild) && (node !== post.nodes.root)) { $.prepend(frag, node); } $.prepend(frag, post.nodes.root); ReplyPruning.hidden--; if (post.file) { ReplyPruning.hiddenFiles--; } } } $.after(ReplyPruning.summary, frag); $.event('PostsInserted', null, ReplyPruning.summary.parentNode); } ReplyPruning.summary.textContent = ReplyPruning.active ? g.SITE.Build.summaryText('+', ReplyPruning.hidden, ReplyPruning.hiddenFiles) : g.SITE.Build.summaryText('-', ReplyPruning.total, ReplyPruning.totalFiles); ReplyPruning.summary.hidden = (ReplyPruning.total <= +Conf["Max Replies"]); if ((hidden1 !== hidden2) && ((boardTop = Header.getTopOf($('.board'))) < 0)) { return window.scrollBy(0, Math.max(d.body.clientHeight - oldPos, window.scrollY + boardTop) - window.scrollY); } } }; var QuoteThreading = { controls: null, threadNewLink: null, input: null, entry: null, ready: null, thread: null, init() { if (!Conf['Quote Threading'] || (g.VIEW !== 'thread')) { return; } this.controls = $.el('label', { innerHTML: " Threading" }); this.threadNewLink = $.el('span', { className: 'brackets-wrap threadnewlink', hidden: true }); $.extend(this.threadNewLink, { innerHTML: "Thread New Posts" }); this.input = $('input', this.controls); this.input.checked = Conf['Thread Quotes']; $.on(this.input, 'change', this.setEnabled); $.on(this.input, 'change', this.rethread); $.on(this.threadNewLink.firstElementChild, 'click', this.rethread); $.on(d, '4chanXInitFinished', () => { this.ready = true; }); Header.menu.addEntry(this.entry = { el: this.controls, order: 99 }); Callbacks.Thread.push({ name: 'Quote Threading', cb: this.setThread }); Callbacks.Post.push({ name: 'Quote Threading', cb: this.node }); }, parent: dict(), children: dict(), inserted: dict(), toggleThreading() { this.setThreadingState(!Conf['Thread Quotes']); }, setThreadingState(enabled) { this.input.checked = enabled; this.setEnabled.call(this.input); this.rethread.call(this.input); }, setEnabled() { if (this.checked) { $.set('Prune All Threads', false); const other = ReplyPruning.inputs?.enabled; if (other?.checked) { other.checked = false; $.event('change', null, other); } } $.cb.checked.call(this); }, setThread() { QuoteThreading.thread = this; $.asap((() => !Conf['Thread Updater'] || $('.navLinksBot > .updatelink')), function () { let navLinksBot; if (navLinksBot = $('.navLinksBot')) { $.add(navLinksBot, [$.tn(' '), QuoteThreading.threadNewLink]); } }); }, node() { let parent; if (this.isFetchedQuote || this.isClone || !this.isReply) { return; } const parents = new Set(); let lastParent = null; for (var quote of this.quotes) { if ((parent = g.posts.get(quote))) { if (!parent.isFetchedQuote && parent.isReply && (parent.ID < this.ID)) { parents.add(parent.ID); if (!lastParent || (parent.ID > lastParent.ID)) { lastParent = parent; } } } } if (!lastParent) return; let ancestor = lastParent; while ((ancestor = QuoteThreading.parent[ancestor.fullID])) { parents.delete(ancestor.ID); } if (parents.size === 1) { QuoteThreading.parent[this.fullID] = lastParent; } }, descendants(post) { let children; let posts = [post]; if (children = QuoteThreading.children[post.fullID]) { for (var child of children) { posts = posts.concat(QuoteThreading.descendants(child)); } } return posts; }, insert(post) { let parent, x; if (!(Conf['Thread Quotes'] && (parent = QuoteThreading.parent[post.fullID]) && !QuoteThreading.inserted[post.fullID])) { return false; } const descendants = QuoteThreading.descendants(post); if (!Unread.posts.has(parent.ID)) { if ((function () { for (var x of descendants) { if (Unread.posts.has(x.ID)) { return true; } } })()) { QuoteThreading.threadNewLink.hidden = false; return false; } } const { order } = Unread; const children = (QuoteThreading.children[parent.fullID] || (QuoteThreading.children[parent.fullID] = [])); const threadContainer = parent.nodes.threadContainer || $.el('div', { className: 'threadContainer' }); const nodes = [post.nodes.root]; if (post.nodes.threadContainer) { nodes.push(post.nodes.threadContainer); } let i = children.length; for (let j = children.length - 1; j >= 0; j--) { var child = children[j]; if (child.ID >= post.ID) { i--; } } if (i !== children.length) { const next = children[i]; for (x of descendants) { order.before(order[next.ID], order[x.ID]); } children.splice(i, 0, post); $.before(next.nodes.root, nodes); } else { let prev2; let prev = parent; while ((prev2 = QuoteThreading.children[prev.fullID]) && prev2.length) { prev = prev2[prev2.length - 1]; } for (let k = descendants.length - 1; k >= 0; k--) { x = descendants[k]; order.after(order[prev.ID], order[x.ID]); } children.push(post); $.add(threadContainer, nodes); } QuoteThreading.inserted[post.fullID] = true; if (!parent.nodes.threadContainer) { parent.nodes.threadContainer = threadContainer; $.addClass(parent.nodes.root, 'threadOP'); $.after(parent.nodes.root, threadContainer); } return true; }, rethread() { if (!QuoteThreading.ready) { return; } const { thread } = QuoteThreading; const { posts } = thread; QuoteThreading.threadNewLink.hidden = true; if (Conf['Thread Quotes']) { posts.forEach(QuoteThreading.insert); } else { const nodes = []; Unread.order = new RandomAccessList(); QuoteThreading.inserted = dict(); posts.forEach(function (post) { if (post.isFetchedQuote) { return; } Unread.order.push(post); if (post.isReply) { nodes.push(post.nodes.root); } if (QuoteThreading.children[post.fullID]) { delete QuoteThreading.children[post.fullID]; $.rmClass(post.nodes.root, 'threadOP'); $.rm(post.nodes.threadContainer); delete post.nodes.threadContainer; } }); $.add(thread.nodes.root, nodes); } Unread.position = Unread.order.first; Unread.updatePosition(); Unread.setLine(true); Unread.read(); Unread.update(); }, }; const RestoreDeletedFromArchive = { restore() { const { boardID, threadID } = g; if (!boardID || threadID == null) return; const url = Redirect.to('threadJSON', { boardID, threadID }); if (!url) { new Notice('warning', 'No archive found', 3); return; } const encryptionOK = url.startsWith('https://'); if (encryptionOK || Conf['Exempt Archives from Encryption']) { CrossOrigin.ajax(url, { onloadend() { if (this.status < 200 || this.status >= 400) { const domain = E(new URL(url).origin); new Notice('error', $.el('div', { innerHTML: 'There was an error while fetching from the archive. See the console for details.
' + 'Some archive check the browser first before checking content, you might need to open the archive ' + `first to get past the browser check: ${domain}
` + 'If that doesn\'t work, try a different archive under Settings > Advanced > Archives > Thread fetching.' })); console.error(this); return; } let nrRestored = 0; const archivePosts = this.response[threadID.toString()].posts; for (const [postID, raw] of Object.entries(archivePosts)) { if (RestoreDeletedFromArchive.insert(raw)[1]) { ++nrRestored; } } let msg; if (nrRestored === 0) { msg = 'No removed posts found'; } else if (nrRestored === 1) { msg = '1 post restored'; } else { msg = `${nrRestored} posts restored`; } new Notice('info', msg, 3); } }); } }, init() { if (g.VIEW !== 'thread') return; const menuEntry = $.el('a', { href: 'javascript:;', textContent: 'Restore from archive', }); $.on(menuEntry, 'click', () => { RestoreDeletedFromArchive.restore(); Header.menu.close(); }); Header.menu.addEntry({ el: menuEntry, order: 10, }); }, insert(raw) { const key = `${raw.board.shortname}.${raw.num}`; const posts = g.posts; if (!posts) return [undefined, false]; if (posts.keys.includes(key)) return [undefined, false]; let inserted = false; const post = parseArchivePost(raw); post.resurrect(); post.markAsFromArchive(); if (post.threadID === g.threadID && g.VIEW === 'thread') { const newPostIndex = posts.insert(key, post, key => +(key.split('.')[1]) < post.ID); if (Conf['Thread Quotes']) { post.thread.nodes.root.insertAdjacentElement('beforeend', post.root); } else { posts.get(posts.keys[newPostIndex - 1]).root.insertAdjacentElement('afterend', post.root); } QuoteThreading.insert(post); inserted = true; for (const quotelink of Get.allQuotelinksLinkingTo(post)) { quotelink.href = `#p${post.ID}`; } } return [post, inserted]; }, }; class Fetcher { constructor(boardID, threadID, postID, root, quoter) { let post, thread; this.boardID = boardID; this.threadID = threadID; this.postID = postID; this.root = root; this.quoter = quoter; if (post = g.posts.get(`${this.boardID}.${this.postID}`)) { this.insert(post); return; } if ((post = Index.replyData?.[`${this.boardID}.${this.postID}`]) && (thread = g.threads.get(`${this.boardID}.${this.threadID}`))) { const board = g.boards[this.boardID]; post = new Post(g.SITE.Build.postFromObject(post, this.boardID), thread, board, { isFetchedQuote: true }); Main.callbackNodes('Post', [post]); this.insert(post); return; } this.root.textContent = `Loading post No.${this.postID}...`; if (this.threadID) { const that = this; $.cache(g.SITE.urls.threadJSON({ boardID: this.boardID, threadID: this.threadID }), function ({ isCached }) { return that.fetchedPost(this, isCached); }); } else { this.archivedPost(); } } insert(post) { if (!this.root.parentNode) { return; } if (!this.quoter) { this.quoter = post; } const clone = post.addClone(this.quoter.context, ($.hasClass(this.root, 'dialog'))); Main.callbackNodes('Post', [clone]); const { nodes } = clone; $.rmAll(nodes.root); $.add(nodes.root, nodes.post); const quotes = [...clone.nodes.quotelinks, ...clone.nodes.backlinks]; for (var quote of quotes) { var { boardID, postID } = Get.postDataFromLink(quote); if ((postID === this.quoter.ID) && (boardID === this.quoter.board.ID)) { $.addClass(quote, 'forwardlink'); } } if (clone.nodes.flag && !(Fetcher.flagCSS || (Fetcher.flagCSS = $('link[href^="//s.4cdn.org/css/flags."]')))) { const cssVersion = $('link[href^="//s.4cdn.org/css/"]')?.href.match(/\d+(?=\.css$)|$/)[0] || Date.now(); Fetcher.flagCSS = $.el('link', { rel: 'stylesheet', href: `//s.4cdn.org/css/flags.${cssVersion}.css` }); $.add(d.head, Fetcher.flagCSS); } $.rmAll(this.root); $.add(this.root, nodes.root); return $.event('PostsInserted', null, this.root); } fetchedPost(req, isCached) { let post; if (post = g.posts.get(`${this.boardID}.${this.postID}`)) { this.insert(post); return; } const { status } = req; if (status !== 200) { if (status && this.archivedPost()) { return; } $.addClass(this.root, 'warning'); this.root.textContent = status === 404 ? `Thread No.${this.threadID} 404'd.` : !status ? 'Connection Error' : `Error ${req.statusText} (${req.status}).`; return; } const { posts } = req.response; g.SITE.Build.spoilerRange[this.boardID] = posts[0].custom_spoiler; for (post of posts) { if (post.no === this.postID) { break; } } if (post.no !== this.postID) { if (isCached) { const api = g.SITE.urls.threadJSON({ boardID: this.boardID, threadID: this.threadID }); $.cleanCache(url => url === api); const that = this; $.cache(api, function () { return that.fetchedPost(this, false); }); return; } if (this.archivedPost()) { return; } $.addClass(this.root, 'warning'); this.root.textContent = `Post No.${this.postID} was not found.`; return; } const board = g.boards[this.boardID] || new Board(this.boardID); const thread = g.threads.get(`${this.boardID}.${this.threadID}`) || new Thread(String(this.threadID), board); post = new Post(g.SITE.Build.postFromObject(post, this.boardID), thread, board, { isFetchedQuote: true }); Main.callbackNodes('Post', [post]); return this.insert(post); } archivedPost() { let url; if (!Conf['Resurrect Quotes']) { return false; } if (!(url = Redirect.to('post', { boardID: this.boardID, postID: this.postID }))) { return false; } const archive = Redirect.data.post[this.boardID]; const encryptionOK = /^https:\/\//.test(url) || (location.protocol === 'http:'); if (encryptionOK || Conf['Exempt Archives from Encryption']) { const that = this; CrossOrigin.cache(url, function () { if (!encryptionOK && this.response?.media) { const { media } = this.response; for (var key in media) { if (/_link$/.test(key)) { if (!$.getOwn(media, key)?.match(/^http:\/\//)) { delete media[key]; } } } } return that.parseArchivedPost(this.response, url, archive); }); return true; } return false; } parseArchivedPost(data , url, archive) { let post; if (post = g.posts.get(`${this.boardID}.${this.postID}`)) { this.insert(post); return; } if (data == null) { $.addClass(this.root, 'warning'); this.root.textContent = `Error fetching Post No.${this.postID} from ${archive.name}.`; return; } if (data.error) { $.addClass(this.root, 'warning'); this.root.textContent = data.error; return; } return this.insert(RestoreDeletedFromArchive.insert(data)[0]); } } Fetcher.archiveTags = { '\n': { innerHTML: "
" }, '[b]': { innerHTML: "" }, '[/b]': { innerHTML: "" }, '[spoiler]': { innerHTML: "" }, '[/spoiler]': { innerHTML: "" }, '[code]': { innerHTML: "
" },
		'[/code]': { innerHTML: "
" }, '[moot]': { innerHTML: "
" }, '[/moot]': { innerHTML: "
" }, '[banned]': { innerHTML: "" }, '[/banned]': { innerHTML: "" }, '[fortune]'(text) { return { innerHTML: "" }; }, '[/fortune]': { innerHTML: "" }, '[i]': { innerHTML: "" }, '[/i]': { innerHTML: "" }, '[red]': { innerHTML: "" }, '[/red]': { innerHTML: "" }, '[green]': { innerHTML: "" }, '[/green]': { innerHTML: "" }, '[blue]': { innerHTML: "" }, '[/blue]': { innerHTML: "" } }; var QuotePreview = { init() { if (!Conf['Quote Previewing']) { return; } if (g.VIEW === 'archive') { $.on(d, 'mouseover', function (e) { if ((e.target.nodeName === 'A') && $.hasClass(e.target, 'quotelink')) { return QuotePreview.mouseover.call(e.target, e); } }); } if (g.VIEW !== 'index' && g.VIEW !== 'thread') { return; } if (Conf['Comment Expansion']) { ExpandComment.callbacks.push(this.node); } return Callbacks.Post.push({ name: 'Quote Previewing', cb: this.node }); }, node() { for (var link of this.nodes.quotelinks.concat([...this.nodes.backlinks], this.nodes.archivelinks)) { $.on(link, 'mouseover', QuotePreview.mouseover); } }, mouseover(e) { let origin; if ($.hasClass(doc, 'xt-mobile')) { return; } if (($.hasClass(this, 'inlined') && !$.hasClass(doc, 'catalog-mode')) || !d.contains(this)) { return; } const { boardID, threadID, postID } = Get.postDataFromLink(this); const qp = $.el('div', { id: 'qp', className: 'dialog' }); $.add(Header.hover, qp); new Fetcher(boardID, threadID, postID, qp, Get.postFromNode(this)); UI.hover({ root: this, el: qp, latestEvent: e, endEvents: 'mouseout click', cb: QuotePreview.mouseout }); if (Conf['Quote Highlighting'] && (origin = g.posts.get(`${boardID}.${postID}`))) { const posts = [origin].concat(origin.clones); posts.pop(); for (var post of posts) { $.addClass(post.nodes.post, 'qphl'); } } }, mouseout() { let root; if (!(root = this.el.firstElementChild)) { return; } $.event('PostsRemoved', null, Header.hover); const clone = Get.postFromRoot(root); if (!clone) { return; } let post = clone.origin; post.rmClone(root.dataset.clone); if (!Conf['Quote Highlighting']) { return; } for (post of [post].concat(post.clones)) { $.rmClass(post.nodes.post, 'qphl'); } } }; var NavLinksPage = `Index Catalog Archive Bottom × Options× Reverse order Sort Image size Columns View mode `; var PageList = `
`; function view() { return d.defaultView || (typeof self !== 'undefined' ? self : null); } function isSupported() { const w = view(); return !!(w && w.CSS && w.CSS.highlights && w.Highlight); } function wbrBridged(a, b) { if (a.parentNode !== b.parentNode) return false; for (let s = a.nextSibling; s && s !== b; s = s.nextSibling) { if (s.nodeType !== Node.ELEMENT_NODE || s.tagName !== 'WBR') return false; } return true; } function locateInRun(map, pos) { for (const e of map) { if (pos <= e.start + e.len) return { node: e.node, offset: pos - e.start }; } const last = map[map.length - 1]; return { node: last.node, offset: last.len }; } function collectRunMatches(run, rx, ranges) { rx.lastIndex = 0; if (run.length === 1) { const node = run[0]; const text = node.nodeValue || ''; let m; while ((m = rx.exec(text))) { if (!m[0]) { rx.lastIndex++; continue; } const range = d.createRange(); range.setStart(node, m.index); range.setEnd(node, m.index + m[0].length); ranges.push(range); } return; } let combined = ''; const map = []; for (const node of run) { const len = (node.nodeValue || '').length; map.push({ node, start: combined.length, len }); combined += node.nodeValue || ''; } let m; while ((m = rx.exec(combined))) { if (!m[0]) { rx.lastIndex++; continue; } const s = locateInRun(map, m.index); const e = locateInRun(map, m.index + m[0].length); const range = d.createRange(); range.setStart(s.node, s.offset); range.setEnd(e.node, e.offset); ranges.push(range); } } var SearchHighlight = { get supported() { return isSupported(); }, escape(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }, rangesFor(root, rx) { const ranges = []; if (!root) return ranges; const walker = d.createTreeWalker(root, NodeFilter.SHOW_TEXT); const texts = []; let node; while ((node = walker.nextNode())) { if (node.nodeValue) texts.push(node); } for (let i = 0; i < texts.length;) { const run = [texts[i]]; while (i + 1 < texts.length && wbrBridged(texts[i], texts[i + 1])) { run.push(texts[++i]); } i++; collectRunMatches(run, rx, ranges); } return ranges; }, setRanges(name, ranges) { const w = view(); if (!w || !w.CSS || !w.CSS.highlights || !w.Highlight) return false; if (ranges && ranges.length) { w.CSS.highlights.set(name, new w.Highlight(...ranges)); } else { w.CSS.highlights.delete(name); } return true; }, apply(name, roots, terms) { const w = view(); if (!w || !w.CSS || !w.CSS.highlights || !w.Highlight) return false; const list = Array.isArray(roots) ? roots : [roots]; if (!terms.length || !list.length) { w.CSS.highlights.delete(name); return true; } const rx = RegExp(terms.map(SearchHighlight.escape).join('|'), 'gi'); const ranges = []; for (const root of list) { ranges.push(...SearchHighlight.rangesFor(root, rx)); } return SearchHighlight.setRanges(name, ranges); }, clear(name) { const w = view(); if (w && w.CSS && w.CSS.highlights) w.CSS.highlights.delete(name); }, }; const INDEX_SEARCH_HL = 'fourchanx-index-search'; const SEARCH_HIT_CLASS = 'search-hit'; const SEARCH_MATCH_CLASS = 'index-search-matched'; const SEARCH_FIELD_TARGETS = { comment: { kind: 'text', sel: '.postMessage' }, subject: { kind: 'text', sel: '.subject' }, name: { kind: 'text', sel: '.nameBlock .name' }, tripcode: { kind: 'text', sel: '.postertrip' }, capcode: { kind: 'text', sel: '.capcode' }, uniqueID: { kind: 'text', sel: '.posteruid' }, postID: { kind: 'text', sel: '.postNum' }, filename: { kind: 'text', sel: '.fileText a' }, dimensions: { kind: 'text', sel: '.fileText' }, filesize: { kind: 'text', sel: '.fileText' }, email: { kind: 'element', sel: '.useremail' }, flag: { kind: 'element', sel: '.flag, .bfl' }, pass: { kind: 'element', sel: '.n-pu' }, MD5: { kind: 'element', sel: '.fileThumb, a.catalog-link' }, }; const SEARCH_FALLBACK_SEL = 'a.catalog-link, .fileThumb'; var Index = { showHiddenThreads: false, changed: {}, button: null, enabled: false, currentPage: null, currentSort: null, hideLabel: null, initFinishedFired: false, inputs: null, lastLongOptions: null, lastLongThresholds: null, lastReadPostsDB: null, liveThreadData: null, liveThreadDict: null, liveThreadIDs: null, loaded: false, nTimeout: null, navLinks: null, notice: null, pageNum: null, pagelist: null, pagesNum: null, parsedThreads: null, replyData: null, req: null, root: null, search: '', searchInput: null, selectMode: null, selectRev: null, selectSort: null, selectColumns: null, controlsToggle: null, searchToggle: null, columnsResizeTimeout: 0, sortedThreadIDs: null, threadPosition: null, threadsNumPerPage: null, threadsWithYous: null, expandedSearchText: dict(), enabledOn({ siteID, boardID }) { return Conf['JSON Index'] && (g.sites[siteID].software === 'yotsuba') && (boardID !== 'f'); }, init() { let input, inputs, name; if (g.VIEW !== 'index') { return; } $.one(d, '4chanXInitFinished', this.cb.initFinished); $.on(d, 'PostsInserted', this.cb.postsInserted); if (!this.enabledOn(g.BOARD)) { return; } this.enabled = true; Callbacks.Post.push({ name: 'Index Page Numbers', cb: this.node }); Callbacks.CatalogThread.push({ name: 'Catalog Features', cb: this.catalogNode }); this.search = history.state?.searched || ''; if (history.state?.mode) { Conf['Index Mode'] = history.state?.mode; } this.currentSort = history.state?.sort; if (!this.currentSort) { this.currentSort = typeof Conf['Index Sort'] === 'object' ? (Conf['Index Sort'][g.BOARD.ID] || 'bump') : (Conf['Index Sort']); } this.currentPage = this.getCurrentPage(); this.processHash(); $.addClass(doc, 'index-loading', `${Conf['Index Mode'].replace(/\ /g, '-')}-mode`); $.on(window, 'popstate', this.cb.popstate); $.on(d, 'scroll', this.scroll); $.on(d, 'SortIndex', this.cb.resort); this.button = $.el('a', { title: 'Refresh', href: 'javascript:;', }); Icon.set(this.button, 'refresh', 'Refresh'); $.on(this.button, 'click', (e) => { e.preventDefault(); Index.update(); }); Header.addShortcut('index-refresh', this.button, 590); const entries = []; this.inputs = (inputs = dict()); for (name in Config.Index) { var arr = Config.Index[name]; if (arr instanceof Array) { var label = UI.checkbox(name, `${name[0]}${name.slice(1).toLowerCase()}`); label.title = arr[1]; entries.push({ el: label }); input = label.firstChild; $.on(input, 'change', $.cb.checked); inputs[name] = input; } } $.on(inputs['Show Replies'], 'change', this.cb.replies); $.on(inputs['Catalog Hover Expand'], 'change', this.cb.hover); $.on(inputs['Pin Watched Threads'], 'change', this.cb.resort); $.on(inputs['Anchor Hidden Threads'], 'change', this.cb.resort); const watchSettings = function (e) { const target = e.target; if (input = $.getOwn(inputs, target.name)) { input.checked = target.checked; return $.event('change', null, input); } }; $.on(d, 'OpenSettings', () => $.on($.id('fourchanx-settings'), 'change', watchSettings)); const sortEntry = UI.checkbox('Per-Board Sort Type', 'Per-board sort type', (typeof Conf['Index Sort'] === 'object')); sortEntry.title = 'Set the sorting order of each board independently.'; $.on(sortEntry.firstChild, 'change', this.cb.perBoardSort); entries.splice(3, 0, { el: sortEntry }); Header.menu.addEntry({ el: $.el('span', { textContent: 'Index Navigation' }), order: 100, subEntries: entries }); this.navLinks = $.el('div', { className: 'navLinks json-index' }); $.extend(this.navLinks, { innerHTML: NavLinksPage }); $('.cataloglink a', this.navLinks).href = CatalogLinks.catalog(); if (!BoardConfig.isArchived(g.BOARD.ID)) { $('.archlistlink', this.navLinks).hidden = true; } $.on($('#index-last-refresh a', this.navLinks), 'click', this.cb.refreshFront); this.searchInput = $('#index-search', this.navLinks); this.setupSearch(); this.setupSearchHelp(); $.on(this.searchInput, 'input', this.onSearchInput); $.on($('#index-search-clear', this.navLinks), 'click', this.clearSearch); Icon.set($('#index-search-clear', this.navLinks), 'xmark'); this.hideLabel = $('#hidden-label', this.navLinks); $.on($('#hidden-toggle a', this.navLinks), 'click', this.cb.toggleHiddenThreads); this.selectRev = $('#index-rev', this.navLinks); this.selectMode = $('#index-mode', this.navLinks); this.selectSort = $('#index-sort', this.navLinks); this.selectSize = $('#index-size', this.navLinks); this.selectColumns = $('#index-columns', this.navLinks); $.on(this.selectRev, 'change', this.cb.sort); $.on(this.selectMode, 'change', this.cb.mode); $.on(this.selectSort, 'change', this.cb.sort); $.on(this.selectSize, 'change', $.cb.value); $.on(this.selectSize, 'change', this.cb.size); $.on(this.selectColumns, 'change', $.cb.value); $.on(this.selectColumns, 'change', this.cb.size); $.on(window, 'resize', this.cb.columnsResize); for (var select of [this.selectMode, this.selectSize, this.selectColumns]) { select.value = Conf[select.name]; } this.searchToggle = $('#index-search-toggle', this.navLinks); Icon.set(this.searchToggle, 'magnifyingGlass'); $.on(this.searchToggle, 'click', this.cb.toggleSearch); this.controlsToggle = $('#index-controls-toggle', this.navLinks); Icon.set(this.controlsToggle, 'sliders'); $.on(this.controlsToggle, 'click', this.cb.toggleOptions); if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { const searchRow = $('#index-search-row', this.navLinks); Header.addShortcut('index-search', this.searchToggle, 820); Header.addShortcut('index-options', this.controlsToggle, 830); $.add(Header.bar, searchRow); $.add($('#index-options', this.navLinks), this.hideLabel); } if (Index.search) { this.cb.toggleSearch(); } $.on($('#index-options-close', this.navLinks), 'click', this.cb.toggleOptions); $.on($('#index-options-backdrop', this.navLinks), 'click', this.cb.toggleOptions); this.selectRev.checked = /-rev$/.test(Index.currentSort); this.selectSort.value = Index.currentSort.replace(/-rev$/, ''); this.lastLongOptions = $('#lastlong-options', this.navLinks); this.lastLongInputs = $$('input', this.lastLongOptions); this.lastLongThresholds = [0, 0]; this.lastLongOptions.hidden = (this.selectSort.value !== 'lastlong'); for (let i = 0; i < this.lastLongInputs.length; i++) { input = this.lastLongInputs[i]; $.on(input, 'change', this.cb.lastLongThresholds); var tRaw = Conf[`Last Long Reply Thresholds ${i}`]; input.value = (this.lastLongThresholds[i] = typeof tRaw === 'object' ? (tRaw[g.BOARD.ID] ?? 100) : tRaw); } this.root = $.el('div', { className: 'board json-index' }); $.on(this.root, 'click', this.cb.hoverToggle); this.cb.size(); this.cb.hover(); this.pagelist = $.el('div', { className: 'pagelist json-index' }); $.extend(this.pagelist, { innerHTML: PageList }); $('.cataloglink a', this.pagelist).href = CatalogLinks.catalog(); $.on(this.pagelist, 'click', this.cb.pageNav); this.update(true); $.onExists(doc, 'title + *', () => d.title = d.title.replace(/\ -\ Page\ \d+/, '')); $.onExists(doc, '.board > .thread > .postContainer, .board + *', function () { let el; g.SITE.Build.hat = $('.board > .thread > img:first-child'); if (g.SITE.Build.hat) { g.BOARD.threads.forEach(function (thread) { if (thread.nodes.root) { return $.prepend(thread.nodes.root, g.SITE.Build.hat.cloneNode(false)); } }); $.addClass(doc, 'hats-enabled'); $.addStyle(`.catalog-thread::after {background-image: url(${g.SITE.Build.hat.src});}`); } const board = $('.board'); $.replace(board, Index.root); if (Index.loaded) { $.event('PostsInserted', null, Index.root); } try { d.implementation.createDocument(null, null, null).appendChild(board); } catch (error) { } for (el of $$('.navLinks')) { $.rm(el); } $.rm($.id('ctrl-top')); const topNavPos = $.id('delform').previousElementSibling; $.before(topNavPos, $.el('hr')); $.before(topNavPos, Index.navLinks); const timeEl = $('#index-last-refresh time', Index.navLinks); if (timeEl.dataset.utc) { return RelativeDates.update(timeEl); } }); return Main.ready(function () { let pagelist; if (pagelist = $('.pagelist')) { $.replace(pagelist, Index.pagelist); } return $.rmClass(doc, 'index-loading'); }); }, scroll() { if (Index.req || !Index.liveThreadData || (Conf['Index Mode'] !== 'infinite') || (window.scrollY <= (doc.scrollHeight - (300 + window.innerHeight)))) { return; } if (Index.pageNum == null) { Index.pageNum = Index.currentPage; } const pageNum = ++Index.pageNum; if (pageNum > Index.pagesNum) { return Index.endNotice(); } const threadIDs = Index.threadsOnPage(pageNum); return Index.buildStructure(threadIDs); }, endNotice: (function () { let notify = false; const reset = () => notify = false; return function () { if (notify) { return; } notify = true; new Notice('info', "Last page reached.", 2); return setTimeout(reset, 3 * SECOND); }; })(), menu: { init() { if ((g.VIEW !== 'index') || !Conf['Menu'] || !Conf['Thread Hiding Link'] || !Index.enabledOn(g.BOARD)) { return; } return Menu.menu.addEntry({ el: $.el('a', { href: 'javascript:;', className: 'has-shortcut-text' }, { innerHTML: "Shift+click" }), order: 20, open({ thread }) { if (Conf['Index Mode'] !== 'catalog') { return false; } this.el.firstElementChild.textContent = thread.isHidden ? 'Unhide' : 'Hide'; if (this.cb) { $.off(this.el, 'click', this.cb); } this.cb = function () { $.event('CloseMenu'); return Index.toggleHide(thread); }; $.on(this.el, 'click', this.cb); return true; } }); } }, node() { if (this.isReply || this.isClone || (Index.threadPosition[this.ID] == null)) { return; } return this.thread.setPage(Math.floor(Index.threadPosition[this.ID] / Index.threadsNumPerPage) + 1); }, hideModsMatch(e) { const hideMods = Conf['Hide thread (catalog click)']; if (!hideMods) return false; const mods = [ e.altKey && 'Alt', e.ctrlKey && 'Ctrl', e.metaKey && 'Meta', e.shiftKey && 'Shift', ].filter(Boolean).join('+'); return mods === hideMods; }, catalogNode() { $.on(this.nodes.root, 'mousedown', (e) => { if (e.button === 0 && Index.hideModsMatch(e)) e.preventDefault(); }); return $.on(this.nodes.root, 'click', (e) => { if (e.button !== 0 || !Index.hideModsMatch(e)) return; e.preventDefault(); getSelection()?.removeAllRanges(); if (e.target.classList.contains('catalog-thumb') && Conf['MD5 Quick Filter in the Catalog']) { Filter.quickFilterMD5.call(this.thread.OP); } else { Index.toggleHide(this.thread); } }); }, toggleHide(thread) { if (Index.showHiddenThreads) { ThreadHiding.show(thread); if (!ThreadHiding.db.get({ boardID: thread.board.ID, threadID: thread.ID })) { return; } } else { ThreadHiding.hide(thread); } return ThreadHiding.saveHiddenState(thread); }, cycleSortType() { let i; const types = Index.selectSort.options.filter((option) => !option.disabled); for (i = 0; i < types.length; i++) { var type = types[i]; if (type.selected) { break; } } types[(i + 1) % types.length].selected = true; return $.event('change', null, Index.selectSort); }, cb: { initFinished() { Index.initFinishedFired = true; return $.queueTask(() => Index.cb.postsInserted()); }, postsInserted() { if (!Index.initFinishedFired) { return; } let n = 0; g.posts.forEach(function (post) { if (!post.isFetchedQuote && !post.indexRefreshSeen && doc.contains(post.nodes.root)) { post.indexRefreshSeen = true; return n++; } }); if (n) { return $.event('IndexRefresh'); } }, toggleHiddenThreads(e) { e?.preventDefault(); $('#hidden-toggle a', Index.navLinks).textContent = (Index.showHiddenThreads = !Index.showHiddenThreads) ? 'Hide' : 'Show'; Index.sort(); return Index.buildIndex(); }, mode() { Index.pushState({ mode: this.value }); return Index.pageLoad(false); }, sort() { const value = Index.selectRev.checked ? Index.selectSort.value + "-rev" : Index.selectSort.value; Index.pushState({ sort: value }); return Index.pageLoad(false); }, resort(e) { Index.changed.order = true; if (!e?.detail?.deferred) { return Index.pageLoad(false); } }, perBoardSort() { Conf['Index Sort'] = this.checked ? dict() : ''; Index.saveSort(); for (let i = 0; i < 2; i++) { Conf[`Last Long Reply Thresholds ${i}`] = this.checked ? dict() : ''; Index.saveLastLongThresholds(i); } }, lastLongThresholds() { const i = [...this.parentNode.children].indexOf(this); const value = +this.value; if (!Number.isFinite(value)) { this.value = Index.lastLongThresholds[i]; return; } Index.lastLongThresholds[i] = value; Index.saveLastLongThresholds(i); Index.changed.order = true; return Index.pageLoad(false); }, size(e) { if (Conf['Index Mode'] !== 'catalog') { $.rmClass(Index.root, 'catalog-small'); $.rmClass(Index.root, 'catalog-large'); } else if (Conf['Index Size'] === 'small') { $.addClass(Index.root, 'catalog-small'); $.rmClass(Index.root, 'catalog-large'); } else { $.addClass(Index.root, 'catalog-large'); $.rmClass(Index.root, 'catalog-small'); } const columns = Index.catalogColumns(); if (columns) { $.addClass(Index.root, 'catalog-columns'); Index.root.style.setProperty('--catalog-columns', String(columns)); const baseWidth = columns === 1 ? 540 : 270; const ratio = Math.round(baseWidth / Index.catalogThumbScale()); Index.root.style.setProperty('--catalog-tile-aspect', `${ratio} / 410`); } else { $.rmClass(Index.root, 'catalog-columns'); Index.root.style.removeProperty('--catalog-columns'); Index.root.style.removeProperty('--catalog-tile-aspect'); } if (e) { return Index.buildIndex(); } }, toggleOptions() { $.toggleClass(doc, 'index-options-open'); }, toggleSearch() { const open = doc.classList.toggle('xt-index-search-open'); Index.searchToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); if (open && doc.classList.contains('xt-mobile')) { $.rmClass(Header.bar, 'autohide', 'scroll'); Index.searchInput.focus(); } }, columnsResize() { if (!Index.catalogColumns()) { return; } clearTimeout(Index.columnsResizeTimeout); Index.columnsResizeTimeout = window.setTimeout(Index.resizeCatalogViews, 100); }, replies() { return Index.buildIndex(); }, hover() { return doc.classList.toggle('catalog-hover-expand', Conf['Catalog Hover Expand']); }, hoverToggle(e) { if (Conf['Catalog Hover Toggle'] && $.hasClass(doc, 'catalog-mode') && !$.modifiedClick(e) && !$.x('ancestor-or-self::a', e.target)) { let thread; const input = Index.inputs['Catalog Hover Expand']; input.checked = !input.checked; $.event('change', null, input); if (thread = Get.threadFromNode(e.target)) { Index.cb.catalogReplies.call(thread); return Index.cb.hoverAdjust.call(thread.OP.nodes); } } }, popstate(e) { if (e?.state) { const { searched, mode, sort } = e.state; const page = Index.getCurrentPage(); Index.setState({ search: searched, mode, sort, page }); return Index.pageLoad(false); } else { const nCommands = Index.processHash(); if (Conf['Refreshed Navigation'] && nCommands) { return Index.update(); } else { return Index.pageLoad(); } } }, pageNav(e) { let a; if ($.modifiedClick(e)) { return; } const target = e.target; switch (target.nodeName) { case 'BUTTON': target.blur(); a = target.parentNode; break; case 'A': a = target; break; default: return; } if (a.textContent === 'Catalog') { return; } e.preventDefault(); return Index.userPageNav(+a.pathname.split(/\/+/)[2] || 1); }, refreshFront(e) { e?.preventDefault(); Index.pushState({ page: 1 }); return Index.update(); }, catalogReplies() { if (Conf['Show Replies'] && $.hasClass(doc, 'catalog-hover-expand') && !this.catalogView.nodes.replies) { return Index.buildCatalogReplies(this); } }, hoverAdjust() { let x; if (!$.hasClass(doc, 'catalog-hover-expand')) { return; } const rect = this.post.getBoundingClientRect(); if (x = $.minmax(0, -rect.left, doc.clientWidth - rect.right)) { const { style } = this.post; style.left = `${x}px`; style.right = `${-x}px`; return $.one(this.root, 'mouseleave', () => style.left = (style.right = null)); } }, searchHelp(e) { e.preventDefault(); e.stopPropagation(); const { popover } = Index.searchHelpNodes(); if (!popover) { return; } return Index.setSearchHelp(popover.hidden); }, closeSearchHelpOutside(e) { const { popover, wrap } = Index.searchHelpNodes(); if (!popover || popover.hidden) { return; } if (wrap?.contains(e.target)) { return; } return Index.setSearchHelp(false); }, closeSearchHelpOnEscape(e) { const { popover } = Index.searchHelpNodes(); if (!popover || popover.hidden || (e.key !== 'Escape')) { return; } e.preventDefault(); return Index.setSearchHelp(false); } }, scrollToIndex() { return Header.scrollToIfNeeded((Index.navLinks.getBoundingClientRect().height ? Index.navLinks : Index.root)); }, getCurrentPage() { return +window.location.pathname.split(/\/+/)[2] || 1; }, userPageNav(page) { Index.pushState({ page }); if (Conf['Refreshed Navigation']) { return Index.update(); } else { return Index.pageLoad(); } }, hashCommands: { mode: { 'paged': 'paged', 'infinite-scrolling': 'infinite', 'infinite': 'infinite', 'all-threads': 'all pages', 'all-pages': 'all pages', 'catalog': 'catalog' }, sort: { 'bump-order': 'bump', 'last-reply': 'lastreply', 'last-long-reply': 'lastlong', 'creation-date': 'birth', 'reply-count': 'replycount', 'file-count': 'filecount', 'posts-per-minute': 'activity' } }, processHash() { let hash = location.href.match(/#.*/)?.[0] || ''; const state = { replace: true }; const commands = hash.slice(1).split('/'); const leftover = []; for (var command of commands) { var mode, sort; if (mode = $.getOwn(Index.hashCommands.mode, command)) { state.mode = mode; } else if (command === 'index') { state.mode = Conf['Previous Index Mode']; state.page = 1; } else if (sort = $.getOwn(Index.hashCommands.sort, command.replace(/-rev$/, ''))) { state.sort = sort; if (/-rev$/.test(command)) { state.sort += '-rev'; } } else if (/^s=/.test(command)) { state.search = decodeURIComponent(command.slice(2)).replace(/\+/g, ' ').trim(); } else { leftover.push(command); } } hash = leftover.join('/'); if (hash) { state.hash = `#${hash}`; } Index.pushState(state); return commands.length - leftover.length; }, pushState(state) { let { search, hash, replace } = state; let pageBeforeSearch = history.state?.oldpage; if ((search != null) && (search !== Index.search)) { state.page = search ? 1 : (pageBeforeSearch || 1); if (!search) { pageBeforeSearch = undefined; } else if (!Index.search) { pageBeforeSearch = Index.currentPage; } } Index.setState(state); const pathname = Index.currentPage === 1 ? `/${g.BOARD}/` : `/${g.BOARD}/${Index.currentPage}`; if (!hash) { hash = ''; } return history[replace ? 'replaceState' : 'pushState']({ mode: Conf['Index Mode'], sort: Index.currentSort, searched: Index.search, oldpage: pageBeforeSearch }, '', `${location.protocol}//${location.host}${pathname}${hash}`); }, setState({ search, mode, sort, page, hash }) { if ((search != null) && (search !== Index.search)) { Index.changed.search = true; Index.search = search; } if ((mode != null) && (mode !== Conf['Index Mode'])) { Index.changed.mode = true; Conf['Index Mode'] = mode; $.set('Index Mode', mode); if ((mode !== 'catalog') && (Conf['Previous Index Mode'] !== mode)) { Conf['Previous Index Mode'] = mode; $.set('Previous Index Mode', mode); } } if ((sort != null) && (sort !== Index.currentSort)) { Index.changed.sort = true; Index.currentSort = sort; Index.saveSort(); } if (['all pages', 'catalog'].includes(Conf['Index Mode'])) { page = 1; } if ((page != null) && (page !== Index.currentPage)) { Index.changed.page = true; Index.currentPage = page; } if (hash != null) { return Index.changed.hash = true; } }, savePerBoard(key, value) { if (typeof Conf[key] === 'object') { Conf[key][g.BOARD.ID] = value; } else { Conf[key] = value; } return $.set(key, Conf[key]); }, saveSort() { return Index.savePerBoard('Index Sort', Index.currentSort); }, saveLastLongThresholds(i) { return Index.savePerBoard(`Last Long Reply Thresholds ${i}`, Index.lastLongThresholds[i]); }, pageLoad(scroll = true) { if (!Index.liveThreadData) { return; } let { threads, order, search, mode, sort, page, hash } = Index.changed; if (!threads) { threads = search; } if (!order) { order = sort; } if (threads || order) { Index.sort(); } if (threads) { Index.buildPagelist(); } if (search) { Index.setupSearch(); } if (mode) { Index.setupMode(); } if (sort) { Index.setupSort(); } if (threads || mode || page || order) { Index.buildIndex(); } if (threads || page) { Index.setPage(); } if (scroll && !hash) { Index.scrollToIndex(); } return Index.changed = {}; }, setupMode() { for (var mode of ['paged', 'infinite', 'all pages', 'catalog']) { $[mode === Conf['Index Mode'] ? 'addClass' : 'rmClass'](doc, `${mode.replace(/\ /g, '-')}-mode`); } Index.selectMode.value = Conf['Index Mode']; Index.cb.size(); Index.showHiddenThreads = false; return $('#hidden-toggle a', Index.navLinks).textContent = 'Show'; }, setupSort() { Index.selectRev.checked = /-rev$/.test(Index.currentSort); Index.selectSort.value = Index.currentSort.replace(/-rev$/, ''); return Index.lastLongOptions.hidden = (Index.selectSort.value !== 'lastlong'); }, getPagesNum() { if (Index.search) { return Math.ceil(Index.sortedThreadIDs.length / Index.threadsNumPerPage); } else { return Index.pagesNum; } }, getMaxPageNum() { return Math.max(1, Index.getPagesNum()); }, buildPagelist() { const pagesRoot = $('.pages', Index.pagelist); const maxPageNum = Index.getMaxPageNum(); if (pagesRoot.childElementCount !== maxPageNum) { const nodes = []; for (let i = 1, end = maxPageNum; i <= end; i++) { var a = $.el('a', { textContent: i, href: i === 1 ? './' : i }); nodes.push($.tn('['), a, $.tn('] ')); } $.rmAll(pagesRoot); return $.add(pagesRoot, nodes); } }, setPage() { let a, strong; const pageNum = Index.currentPage; const maxPageNum = Index.getMaxPageNum(); const pagesRoot = $('.pages', Index.pagelist); const prev = pagesRoot.previousElementSibling?.firstElementChild; const next = pagesRoot.nextElementSibling?.firstElementChild; const setNav = function (link, href, disabled) { if (!link) { return; } link.href = href === 1 ? './' : href; const button = link.firstElementChild; if (button) { button.disabled = disabled; } if (disabled) { link.setAttribute('aria-disabled', 'true'); } else { link.removeAttribute('aria-disabled'); } }; let href = Math.max(pageNum - 1, 1); setNav(prev, href, href === pageNum); href = Math.min(pageNum + 1, maxPageNum); setNav(next, href, href === pageNum); if (strong = $('strong', pagesRoot)) { if (+strong.textContent === pageNum) { return; } $.replace(strong, strong.firstChild); } else { strong = $.el('strong'); } if (a = pagesRoot.children[pageNum - 1]) { $.before(a, strong); return $.add(strong, a); } }, updateHideLabel() { if (!Index.hideLabel) { return; } let hiddenCount = 0; for (var threadID of Index.liveThreadIDs) { if (Index.isHidden(threadID)) { hiddenCount++; } } if (!hiddenCount) { Index.hideLabel.hidden = true; if (Index.showHiddenThreads) { Index.cb.toggleHiddenThreads(); } return; } Index.hideLabel.hidden = false; return $('#hidden-count', Index.navLinks).textContent = hiddenCount === 1 ? '1 hidden thread' : `${hiddenCount} hidden threads`; }, update(firstTime) { let oldReq; if (oldReq = Index.req) { delete Index.req; oldReq.abort(); } if (Conf['Index Refresh Notifications']) { if (!Index.notice) { Index.notice = new Notice('info', 'Refreshing index...'); } if (!Index.nTimeout) { Index.nTimeout = setTimeout(() => { if (Index.notice) { Index.notice.el.lastElementChild.textContent += ' (disable JSON Index if this takes too long)'; } }, 3 * SECOND); } } if (!firstTime && (d.readyState !== 'loading') && !$('.board + *')) { location.reload(); return; } Index.req = $.whenModified(g.SITE.urls.catalogJSON({ boardID: g.BOARD.ID }), 'Index', Index.load); return $.addClass(Index.button, 'spin'); }, load() { let err; if (this !== Index.req) { return; } $.rmClass(Index.button, 'spin'); const { notice, nTimeout } = Index; if (nTimeout) { clearTimeout(nTimeout); } delete Index.nTimeout; delete Index.req; delete Index.notice; if (![200, 304].includes(this.status)) { err = `Index refresh failed. ${this.status ? `Error ${this.statusText} (${this.status})` : 'Connection Error'}`; if (notice) { notice.setType('warning'); notice.el.lastElementChild.textContent = err; setTimeout(notice.close, SECOND); } else { new Notice('warning', err, 1); } return; } try { if (this.status === 200) { Index.parse(this.response); } else if (this.status === 304) { Index.pageLoad(); } } catch (error) { err = error; c.error(`Index failure: ${err.message}`, err.stack); if (notice) { notice.setType('error'); notice.el.lastElementChild.textContent = 'Index refresh failed.'; setTimeout(notice.close, SECOND); } else { new Notice('error', 'Index refresh failed.', 1); } return; } if (notice) { if (Conf['Index Refresh Notifications']) { notice.setType('success'); notice.el.lastElementChild.textContent = 'Index refreshed!'; setTimeout(notice.close, SECOND); } else { notice.close(); } } const timeEl = $('#index-last-refresh time', Index.navLinks); timeEl.dataset.utc = Date.parse(this.getResponseHeader('Last-Modified')); return RelativeDates.update(timeEl); }, parse(pages) { $.cleanCache(url => /^https?:\/\/a\.4cdn\.org\//.test(url)); Index.parseThreadList(pages); Index.changed.threads = true; return Index.pageLoad(); }, parseThreadList(pages) { Index.pagesNum = pages.length; Index.threadsNumPerPage = pages[0]?.threads.length || 1; Index.liveThreadData = pages.reduce(((arr, next) => arr.concat(next.threads)), []); Index.liveThreadIDs = Index.liveThreadData.map((data) => data.no); Index.liveThreadDict = dict(); Index.threadPosition = dict(); Index.parsedThreads = dict(); Index.replyData = dict(); Index.threadsWithYous = dict(); for (let i = 0; i < Index.liveThreadData.length; i++) { var obj, results; var data = Index.liveThreadData[i]; Index.liveThreadDict[data.no] = data; Index.threadPosition[data.no] = i; Index.parsedThreads[data.no] = (obj = g.SITE.Build.parseJSON(data, g.BOARD)); results = Filter.test(obj); obj.isOnTop = results.top; obj.isHidden = results.hide || ThreadHiding.isHidden(obj.boardID, obj.threadID); if (obj.isHidden && Conf['Show Threads With Yous']) { Index.threadsWithYous[data.no] = Index.threadHasUnreadYous(data.no); } if (data.last_replies) { for (var reply of data.last_replies) { Index.replyData[`${g.BOARD}.${reply.no}`] = reply; } } } if (Index.liveThreadData[0]) { g.SITE.Build.spoilerRange[g.BOARD.ID] = Index.liveThreadData[0].custom_spoiler; } g.BOARD.threads.forEach(function (thread) { if (!Index.liveThreadIDs.includes(thread.ID)) { return thread.collect(); } }); $.event('IndexUpdate', { threads: ((Index.liveThreadIDs.map((ID) => `${g.BOARD}.${ID}`))) }); }, isHidden(threadID) { let thread; if ((thread = g.BOARD.threads.get(threadID)) && thread.OP && !thread.OP.isFetchedQuote) { return thread.isHidden; } else { return Index.parsedThreads[threadID].isHidden; } }, isHiddenReply(threadID, replyData) { return PostHiding.isHidden(g.BOARD.ID, threadID, replyData.no) || Filter.isHidden(g.SITE.Build.parseJSON(replyData, g.BOARD)); }, threadHasUnreadYous(threadID) { if (!Conf['Show Threads With Yous'] || !QuoteYou.db) { return false; } const cached = Index.threadsWithYous?.[threadID]; if (cached != null) { return cached; } const threadData = Index.liveThreadDict?.[threadID]; if (!threadData?.last_replies?.length) { return false; } if (!Index.lastReadPostsDB) { Index.lastReadPostsDB = new DataBoard('lastReadPosts'); } const boardID = g.BOARD.ID; const siteID = g.SITE.ID; const lastReadPost = Index.lastReadPostsDB.get({ siteID, boardID, threadID, defaultValue: 0 }); const youOP = !Conf['Require OP Quote Link'] && QuoteYou.db.get({ siteID, boardID, threadID, postID: threadID }); for (var reply of threadData.last_replies) { const postID = reply.no; if (postID <= lastReadPost) { continue; } if (QuoteYou.db.get({ siteID, boardID, threadID, postID })) { continue; } if (youOP) { return Index.threadsWithYous[threadID] = true; } if (!reply.com) { continue; } const regexp = g.SITE.regexp.quotelinkHTML; regexp.lastIndex = 0; let match; while (match = regexp.exec(reply.com)) { if (QuoteYou.db.get({ siteID, boardID: match[1] ? encodeURIComponent(match[1]) : boardID, threadID: match[2] || threadID, postID: match[3] || match[2] || threadID })) { return Index.threadsWithYous[threadID] = true; } } } return Index.threadsWithYous[threadID] = false; }, showThreadInCatalog(threadID) { const hidden = Index.isHidden(threadID); if (Index.showHiddenThreads) { return hidden; } return !hidden || Index.threadHasUnreadYous(threadID); }, buildThreads(threadIDs, isCatalog, withReplies) { let errors; const threads = []; const newThreads = []; let newPosts = []; for (var ID of threadIDs) { var opRoot, thread; try { var OP; var threadData = Index.liveThreadDict[ID]; if (thread = g.BOARD.threads.get(ID)) { var isStale = (thread.json !== threadData) && (JSON.stringify(thread.json) !== JSON.stringify(threadData)); if (isStale) { thread.setCount('post', threadData.replies + 1, threadData.bumplimit); thread.setCount('file', threadData.images + !!threadData.ext, threadData.imagelimit); thread.setStatus('Sticky', !!threadData.sticky); thread.setStatus('Closed', !!threadData.closed); } if (thread.catalogView) { $.rm(thread.catalogView.nodes.replies); thread.catalogView.nodes.replies = null; } } else { thread = new Thread(ID, g.BOARD); newThreads.push(thread); } var lastPost = threadData.last_replies && threadData.last_replies.length ? threadData.last_replies[threadData.last_replies.length - 1].no : ID; if (lastPost > thread.lastPost) { thread.lastPost = lastPost; } thread.json = threadData; threads.push(thread); if ((OP = thread.OP) && !OP.isFetchedQuote) { OP.setCatalogOP(isCatalog); thread.setPage(Math.floor(Index.threadPosition[ID] / Index.threadsNumPerPage) + 1); } else { var obj = Index.parsedThreads[ID]; opRoot = g.SITE.Build.post(obj); OP = new Post(opRoot, thread, g.BOARD); OP.filterResults = obj.filterResults; newPosts.push(OP); } if (!isCatalog || !thread.nodes.root) { g.SITE.Build.thread(thread, threadData, withReplies); } } catch (err) { if (!errors) { errors = []; } errors.push({ message: `Parsing of Thread No.${thread} failed. Thread will be skipped.`, error: err, html: opRoot?.outerHTML }); } } if (errors) { Main.handleErrors(errors); } if (withReplies) { newPosts = newPosts.concat(Index.buildReplies(threads)); } Main.callbackNodes('Thread', newThreads); Main.callbackNodes('Post', newPosts); Index.updateHideLabel(); $.event('IndexRefreshInternal', { threadIDs: (threads.map((t) => t.fullID)), isCatalog }); return threads; }, buildReplies(threads) { let errors; const posts = []; for (var thread of threads) { var lastReplies; if (!(lastReplies = Index.liveThreadDict[thread.ID].last_replies)) { continue; } var nodes = []; for (var data of lastReplies) { var node, post; if ((post = thread.posts.get(data.no)) && !post.isFetchedQuote) { nodes.push(post.nodes.root); continue; } nodes.push(node = g.SITE.Build.postFromObject(data, thread.board.ID)); try { posts.push(new Post(node, thread, thread.board)); } catch (err) { if (!errors) { errors = []; } errors.push({ message: `Parsing of Post No.${data.no} failed. Post will be skipped.`, error: err, html: node?.outerHTML }); } } $.add(thread.nodes.root, nodes); } if (errors) { Main.handleErrors(errors); } return posts; }, buildCatalogViews(threads) { const catalogThreads = []; for (var thread of threads) { if (!thread.catalogView) { var { ID } = thread; var page = Math.floor(Index.threadPosition[ID] / Index.threadsNumPerPage) + 1; var root = g.SITE.Build.catalogThread(thread, Index.liveThreadDict[ID], page); catalogThreads.push(new CatalogThread(root, thread)); } } Main.callbackNodes('CatalogThread', catalogThreads); }, catalogColumns() { if (Conf['Index Mode'] !== 'catalog' || Conf['Catalog Columns'] === 'auto') { return 0; } return $.minmax(parseInt(Conf['Catalog Columns'], 10) || 0, 1, 12); }, catalogColumnWidth(columns) { const rootWidth = Index.root?.clientWidth || doc.clientWidth; return rootWidth / columns; }, catalogThumbScale() { return Conf['Index Size'] === 'small' ? 0.6 : 1; }, sizeCatalogViews(threads) { let size = Conf['Index Size'] === 'small' ? 150 : 250; let fullImage = false; const columns = Index.catalogColumns(); if (columns) { const columnWidth = Index.catalogColumnWidth(Math.max(columns, 2)) * Index.catalogThumbScale(); size = Math.max(50, columnWidth - 14); fullImage = columnWidth > 400; } for (var thread of threads) { var { thumb } = thread.catalogView.nodes; var { width, height } = thumb.dataset; if (!width) { continue; } Index.setCatalogThumbSource(thread, fullImage); var ratio = size / Math.max(width, height); thumb.style.width = (width * ratio) + 'px'; thumb.style.height = (height * ratio) + 'px'; } }, setCatalogThumbSource(thread, fullImage) { const thumb = thread.catalogView.nodes.thumb; const file = thread.OP.file; if (!file?.thumbURL || !file.url || !file.isImage || /\.gif$/i.test(file.url)) { return; } const src = fullImage ? file.url : file.thumbURL; if (thumb.getAttribute('src') !== src) { thumb.loading = 'lazy'; thumb.src = src; } }, resizeCatalogViews() { const threads = []; g.BOARD.threads.forEach((thread) => { if (thread.catalogView) { threads.push(thread); } }); Index.sizeCatalogViews(threads); }, buildCatalogReplies(thread) { let lastReplies; const { nodes } = thread.catalogView; if (!(lastReplies = Index.liveThreadDict[thread.ID].last_replies)) { return; } const replies = []; for (var data of lastReplies) { if (Index.isHiddenReply(thread.ID, data)) { continue; } var reply = g.SITE.Build.catalogReply(thread, data); RelativeDates.update($('time', reply)); $.on($('.catalog-reply-preview', reply), 'mouseover', QuotePreview.mouseover); replies.push(reply); } nodes.replies = $.el('div', { className: 'catalog-replies' }); $.add(nodes.replies, replies); $.add(thread.OP.nodes.post, nodes.replies); }, sort() { let threadIDs; const { liveThreadIDs, liveThreadData } = Index; if (!liveThreadData) { return; } const tmp_time = new Date().getTime() / 1000; const sortType = Index.currentSort.replace(/-rev$/, ''); Index.sortedThreadIDs = (() => { switch (sortType) { case 'lastreply': case 'lastlong': var repliesAvailable = liveThreadData.some((thread) => thread.last_replies?.length); var lastlong = function (thread) { if (!repliesAvailable) { return thread.last_modified; } const iterable = thread.last_replies || []; for (let i = iterable.length - 1; i >= 0; i--) { var r = iterable[i]; if (Index.isHiddenReply(thread.no, r)) { continue; } if (sortType === 'lastreply') { return r; } var len = r.com ? g.SITE.Build.parseComment(r.com).replace(/[^a-z]/ig, '').length : 0; if (len >= Index.lastLongThresholds[+!!r.ext]) { return r; } } if (thread.omitted_posts && thread.last_replies?.length) { return thread.last_replies[0]; } else { return thread; } }; var lastlongD = dict(); for (var thread of liveThreadData) { lastlongD[thread.no] = lastlong(thread).no; } return [...liveThreadData].sort((a, b) => lastlongD[b.no] - lastlongD[a.no]).map(post => post.no); case 'bump': return liveThreadIDs; case 'birth': return [...liveThreadIDs].sort((a, b) => b - a); case 'replycount': return [...liveThreadData].sort((a, b) => b.replies - a.replies).map(post => post.no); case 'filecount': return [...liveThreadData].sort((a, b) => b.images - a.images).map(post => post.no); case 'activity': return [...liveThreadData].sort((a, b) => ((tmp_time - a.time) / (a.replies + 1)) - ((tmp_time - b.time) / (b.replies + 1))).map(post => post.no); default: return liveThreadIDs; } })(); if (/-rev$/.test(Index.currentSort)) { Index.sortedThreadIDs.reverse(); } if (Index.search && (threadIDs = Index.querySearch(Index.search))) { Index.sortedThreadIDs = threadIDs; } Index.sortOnTop((obj) => obj.isSticky); Index.sortOnTop((obj) => obj.isOnTop || (Conf['Pin Watched Threads'] && ThreadWatcher.isWatchedRaw(obj.boardID, obj.threadID))); if (Conf['Anchor Hidden Threads']) { return Index.sortOnTop((obj) => !Index.isHidden(obj.threadID)); } }, sortOnTop(match) { const topThreads = []; const bottomThreads = []; for (var ID of Index.sortedThreadIDs) { (match(Index.parsedThreads[ID]) ? topThreads : bottomThreads).push(ID); } return Index.sortedThreadIDs = topThreads.concat(bottomThreads); }, buildIndex() { let threadIDs; if (!Index.liveThreadData) { return; } switch (Conf['Index Mode']) { case 'all pages': threadIDs = Index.sortedThreadIDs; break; case 'catalog': threadIDs = Index.sortedThreadIDs.filter((ID) => Index.showThreadInCatalog(ID)); break; default: threadIDs = Index.threadsOnPage(Index.currentPage); } delete Index.pageNum; $.rmAll(Index.root); $.rmAll(Header.hover); if (Index.loaded && Index.root.parentNode) { $.event('PostsRemoved', null, Index.root); } if (Conf['Index Mode'] === 'catalog') { Index.buildCatalog(threadIDs); } else { Index.buildStructure(threadIDs); } }, threadsOnPage(pageNum) { const nodesPerPage = Index.threadsNumPerPage; const offset = nodesPerPage * (pageNum - 1); return Index.sortedThreadIDs.slice(offset, offset + nodesPerPage); }, buildStructure(threadIDs) { const threads = Index.buildThreads(threadIDs, false, Conf['Show Replies']); Index.showHiddenThreadsWithYousInIndex(threads); const nodes = []; for (var thread of threads) { nodes.push(thread.nodes.root, $.el('hr')); } $.add(Index.root, nodes); if (Index.root.parentNode) { $.event('PostsInserted', null, Index.root); } Index.highlightSearch(); Index.loaded = true; }, showHiddenThreadsWithYousInIndex(threads) { if (Conf['Index Mode'] === 'catalog' || !Conf['Show Threads With Yous']) { return; } for (var thread of threads) { if (!thread.isHidden || !Index.threadHasUnreadYous(thread.ID)) { continue; } if (thread.stub && thread.nodes.root.contains(thread.stub)) { $.rm(thread.stub); } thread.nodes.root.hidden = false; } }, buildCatalog(threadIDs) { let i = 0; const n = threadIDs.length; let node0 = null; var fn = function () { if (node0 && !node0.parentNode) { return; } const j = (i > 0) && Index.root.parentNode ? n : i + 30; node0 = Index.buildCatalogPart(threadIDs.slice(i, j))[0]; i = j; if (i < n) { return $.queueTask(fn); } else { if (Index.root.parentNode) { $.event('PostsInserted', null, Index.root); } Index.groupHiddenCatalogThreads(threadIDs); Index.highlightSearch(); return Index.loaded = true; } }; fn(); }, groupHiddenCatalogThreads(threadIDs) { if (!Conf['Group Hidden Threads By Filter']) { return; } const hiddenThreadIDs = threadIDs.filter((ID) => Index.isHidden(ID)); if (!hiddenThreadIDs.length) { return; } const groupedThreads = new Map(); const manualHiddenThreads = []; const hiddenNodes = []; for (var threadID of hiddenThreadIDs) { const node = $.id(`t${threadID}`); if (!node || (node.parentNode !== Index.root)) { continue; } hiddenNodes.push(node); Index.clearHiddenFilterValueFromCatalogThread(node); if (ThreadHiding.db?.get({ boardID: g.BOARD.ID, threadID })) { manualHiddenThreads.push(node); continue; } const reason = (Index.parsedThreads[threadID]?.filterResults?.reasons?.[0] || 'Filtered').trim(); const parsed = Index.parseHiddenFilterReason(reason); const key = parsed.key; let group = groupedThreads.get(key); if (!group) { group = { label: parsed.label, nodes: [] }; groupedThreads.set(key, group); } if (parsed.value) { Index.applyHiddenFilterValueToCatalogThread(node, parsed.label, parsed.value); } group.nodes.push(node); } if (!hiddenNodes.length) { return; } for (var hiddenNode of hiddenNodes) { $.rm(hiddenNode); } const frag = d.createDocumentFragment(); for (const [, group] of groupedThreads) { frag.appendChild($.el('div', { className: 'catalog-group-header', textContent: `${group.label} (${group.nodes.length})` })); frag.append(...group.nodes); } if (manualHiddenThreads.length) { frag.appendChild($.el('div', { className: 'catalog-group-header', textContent: `Manually hidden (${manualHiddenThreads.length})` })); frag.append(...manualHiddenThreads); } Index.root.appendChild(frag); }, parseHiddenFilterReason(reason) { const match = reason.match(/^Filtered\s+([A-Za-z0-9_]+)\s+(.+)$/); if (!match) { return { key: reason, label: reason, value: null }; } const type = match[1]; const value = match[2]; const highCardinalityTypes = new Set([ 'MD5', 'postID', 'uniqueID', 'name', 'tripcode', 'email', 'filename', 'capcode', 'flag', 'dimensions', 'filesize' ]); if (!highCardinalityTypes.has(type)) { return { key: reason, label: reason, value: null }; } return { key: `Filtered ${type}`, label: `Filtered ${type}`, value }; }, applyHiddenFilterValueToCatalogThread(node, label, value) { const valueLine = label === 'Filtered MD5' ? value : `${label}: ${value}`; const link = $('.catalog-link', node); if (link) { link.title = valueLine; link.dataset.hiddenFilterMatch = valueLine; } const old = $('.catalog-group-match', node); if (old) { $.rm(old); } const stats = $('.catalog-stats', node); if (!stats) { return; } $.add(stats, $.el('span', { className: 'catalog-group-match', textContent: valueLine })); }, clearHiddenFilterValueFromCatalogThread(node) { const old = $('.catalog-group-match', node); if (old) { $.rm(old); } const link = $('.catalog-link', node); if (link?.dataset.hiddenFilterMatch) { delete link.dataset.hiddenFilterMatch; link.removeAttribute('title'); } }, buildCatalogPart(threadIDs) { const threads = Index.buildThreads(threadIDs, true); Index.buildCatalogViews(threads); Index.sizeCatalogViews(threads); const nodes = []; for (var thread of threads) { Index.clearHiddenFilterValueFromCatalogThread(thread.catalogView.nodes.root); thread.OP.setCatalogOP(true); $.add(thread.catalogView.nodes.root, thread.OP.nodes.root); nodes.push(thread.catalogView.nodes.root); $.on(thread.catalogView.nodes.root, 'mouseenter', Index.cb.catalogReplies.bind(thread)); $.on(thread.OP.nodes.root, 'mouseenter', Index.cb.hoverAdjust.bind(thread.OP.nodes)); } $.add(Index.root, nodes); return nodes; }, clearSearch(e) { e?.preventDefault(); Index.searchInput.value = ''; Index.onSearchInput(); return Index.searchInput.focus(); }, searchHelpNodes() { const navLinks = Index.navLinks; if (!navLinks) { return { help: null, popover: null, wrap: null }; } return { help: $('#index-search-help', navLinks) || $.id('index-search-help'), popover: $('#index-search-help-popover', navLinks) || $.id('index-search-help-popover'), wrap: $('#index-search-help-wrap', navLinks) || $.id('index-search-help-wrap') }; }, setupSearchHelp() { const { help } = Index.searchHelpNodes(); if (!help) { return; } Icon.set(help, 'circleQuestion'); $.on(help, 'click', Index.cb.searchHelp); $.on(d, 'click', Index.cb.closeSearchHelpOutside); return $.on(d, 'keydown', Index.cb.closeSearchHelpOnEscape); }, setSearchHelp(open) { const { help, popover } = Index.searchHelpNodes(); if (!help || !popover) { return; } popover.hidden = !open; return help.setAttribute('aria-expanded', open ? 'true' : 'false'); }, setupSearch() { Index.searchInput.value = Index.search; doc.classList.toggle('xt-index-searching', !!Index.search); if (Index.search) { return Index.searchInput.dataset.searching = 1; } else { return Index.searchInput.removeAttribute('data-searching'); } }, onSearchInput() { const search = Index.searchInput.value.trim(); if (search === Index.search) { return; } Index.pushState({ search, replace: !!search === !!Index.search }); return Index.pageLoad(false); }, parseRegexQuery(query) { const match = query.match(/^([\w+]+):\/(.*)\/(\w*)$/); if (!match) { return null; } if (!match[1].split('+').every((k) => $.hasOwn(Filter.valueF, k))) { return null; } return match; }, parseKeywordQuery(query) { let opOnly = false; const m = query.match(/^op:\s*/i); if (m) { opOnly = true; query = query.slice(m[0].length); } const keywords = Index.tokenizeKeywords(query.toLowerCase()); return { opOnly, keywords }; }, tokenizeKeywords(query) { const keywords = []; const rx = /"([^"]*)"?|(\S+)/g; let m; while ((m = rx.exec(query))) { if (!m[0]) { rx.lastIndex++; continue; } const term = m[1] != null ? m[1].trim().replace(/\s+/g, ' ') : m[2]; if (term) { keywords.push(term); } } return keywords; }, querySearch(query) { let match; if (match = Index.parseRegexQuery(query)) { let regexp; try { regexp = RegExp(match[2], match[3]); } catch (error) { return []; } return Index.sortedThreadIDs.filter((ID) => regexp.test(Filter.values(match[1], Index.parsedThreads[ID]).join('\n'))); } const { opOnly, keywords } = Index.parseKeywordQuery(query); if (!keywords.length) { return; } return Index.sortedThreadIDs.filter((ID) => Index.searchMatch(Index.parsedThreads[ID], keywords, opOnly)); }, getSearchTerms() { const query = Index.search; if (!query || Index.parseRegexQuery(query)) { return []; } return Index.parseKeywordQuery(query).keywords; }, highlightSearch() { for (const el of Index.root.querySelectorAll(`.${SEARCH_HIT_CLASS}, .${SEARCH_MATCH_CLASS}`)) { el.classList.remove(SEARCH_HIT_CLASS, SEARCH_MATCH_CLASS); } const query = Index.search; let match; if (query && (match = Index.parseRegexQuery(query))) { Index.highlightRegexSearch(match); return; } const terms = Index.getSearchTerms(); if (!terms.length) { SearchHighlight.setRanges(INDEX_SEARCH_HL, []); return; } const rx = RegExp(terms.map(SearchHighlight.escape).join('|'), 'gi'); const ranges = SearchHighlight.rangesFor(Index.root, rx); SearchHighlight.setRanges(INDEX_SEARCH_HL, ranges); Index.markMatchedPosts(ranges); }, markMatchedPosts(ranges) { for (const range of ranges) { const node = range.startContainer; const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; el?.closest(`.postContainer, .catalog-thread`)?.classList.add(SEARCH_MATCH_CLASS); } }, highlightRegexSearch(match) { let rx; try { rx = RegExp(match[2], /g/.test(match[3]) ? match[3] : `${match[3] || ''}g`); } catch (error) { SearchHighlight.setRanges(INDEX_SEARCH_HL, []); return; } const fields = match[1].split('+').filter((f) => SEARCH_FIELD_TARGETS[f]); const ranges = []; for (const tile of Index.root.children) { if (tile.tagName === 'HR') { continue; } const opRoot = tile.querySelector('.opContainer') || tile.querySelector('.postContainer') || tile; opRoot.classList.add(SEARCH_MATCH_CLASS); let shown = false; let missed = false; for (const field of fields) { const { kind, sel } = SEARCH_FIELD_TARGETS[field]; const els = opRoot.querySelectorAll(sel); let visible = false; for (const el of els) { if (kind === 'text') { if (el.offsetParent === null) { continue; } const r = SearchHighlight.rangesFor(el, rx); if (r.length) { ranges.push(...r); visible = true; } } else { el.classList.add(SEARCH_HIT_CLASS); if (el.offsetParent !== null) { visible = true; } } } if (visible) { shown = true; } else { missed = true; } } if (missed && !shown) { const fallback = opRoot.querySelector(SEARCH_FALLBACK_SEL) || tile; fallback.classList.add(SEARCH_HIT_CLASS); } } SearchHighlight.setRanges(INDEX_SEARCH_HL, ranges); }, searchMatch(obj, keywords, opOnly) { const text = Index.searchText(obj, opOnly); for (var keyword of keywords) { if (-1 === text.indexOf(keyword)) { return false; } } return true; }, searchText(obj, opOnly) { if (obj._searchTextOP == null) { const { info, file } = obj; if (info.comment == null) { info.comment = g.SITE.Build.parseComment(info.commentHTML.innerHTML); } const parts = []; for (var key of ['comment', 'subject', 'name', 'tripcode']) { if (key in info) { parts.push(info[key]); } } if (file) { parts.push(file.name); } obj._searchTextOP = Linkify.rewriteDisplayText(parts.join(' ')).replace(/\s+/g, ' ').toLowerCase(); } if (opOnly) { return obj._searchTextOP; } if (obj._searchText == null) { const parts = [obj._searchTextOP]; const replies = Index.liveThreadDict[obj.threadID]?.last_replies; if (replies) { for (var reply of replies) { if (reply.sub) { parts.push(reply.sub); } if (reply.name) { parts.push(reply.name); } if (reply.trip) { parts.push(reply.trip); } if (reply.filename) { parts.push(reply.filename + (reply.ext || '')); } if (reply.com) { parts.push(g.SITE.Build.parseComment(reply.com)); } } } obj._searchText = Linkify.rewriteDisplayText(parts.join(' ')).replace(/\s+/g, ' ').toLowerCase(); } const extra = Index.expandedSearchText[obj.threadID]; return extra ? `${obj._searchText} ${extra}` : obj._searchText; }, setExpandedThreadText(threadID, postsData) { if (!postsData) { return; } const parts = []; for (var data of postsData) { if (data.no === threadID) { continue; } if (data.sub) { parts.push(data.sub); } if (data.name) { parts.push(data.name); } if (data.trip) { parts.push(data.trip); } if (data.filename) { parts.push(data.filename + (data.ext || '')); } if (data.com) { parts.push(g.SITE.Build.parseComment(data.com)); } } Index.expandedSearchText[threadID] = parts.join(' ').replace(/\s+/g, ' ').toLowerCase(); }, clearExpandedThreadText(threadID) { delete Index.expandedSearchText[threadID]; } }; const MAX = 10; const UndoStack = { entries: [], record(label, undo) { UndoStack.entries.push({ label, undo }); if (UndoStack.entries.length > MAX) { UndoStack.entries.shift(); } }, undo() { const entry = UndoStack.entries.pop(); if (!entry) { new Notice('info', 'Nothing to undo.', 2); return; } try { entry.undo(); } catch (err) { new Notice('warning', `Couldn't undo: ${entry.label}`, 3); return; } new Notice('info', entry.label, 2); }, }; var ThreadHiding = { db: null, hiddenThreads: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'catalog') || (!Conf['Thread Hiding Buttons'] && !(Conf['Menu'] && Conf['Thread Hiding Link']) && !Conf['JSON Index'])) { return; } this.db = new DataBoard('hiddenThreads'); if (g.VIEW === 'catalog') { return this.catalogWatch(); } this.catalogSet(g.BOARD); $.on(d, 'IndexRefreshInternal', this.onIndexRefresh); if (Conf['Thread Hiding Buttons']) { $.addClass(doc, 'thread-hide'); } return Callbacks.Post.push({ name: 'Thread Hiding', cb: this.node }); }, catalogSet(board) { if (!$.hasStorage || (g.SITE.software !== 'yotsuba')) { return; } const hiddenThreads = ThreadHiding.db.get({ boardID: board.ID, defaultValue: dict() }); for (var threadID in hiddenThreads) { hiddenThreads[threadID] = true; } return localStorage.setItem(`4chan-hide-t-${board}`, JSON.stringify(hiddenThreads)); }, catalogWatch() { if (!$.hasStorage || (g.SITE.software !== 'yotsuba')) { return; } this.hiddenThreads = JSON.parse(localStorage.getItem(`4chan-hide-t-${g.BOARD}`) || '{}') || {}; return Main.ready(() => new MutationObserver(ThreadHiding.catalogSave).observe($.id('threads'), { attributes: true, subtree: true, attributeFilter: ['style'] })); }, catalogSave() { let threadID; const hiddenThreads2 = JSON.parse(localStorage.getItem(`4chan-hide-t-${g.BOARD}`) || '{}') || {}; for (threadID in hiddenThreads2) { if (!$.hasOwn(ThreadHiding.hiddenThreads, threadID)) { ThreadHiding.db.set({ boardID: g.BOARD.ID, threadID, val: { makeStub: Conf['Stubs'] } }); } } for (threadID in ThreadHiding.hiddenThreads) { if (!$.hasOwn(hiddenThreads2, threadID)) { ThreadHiding.db.delete({ boardID: g.BOARD.ID, threadID }); } } return ThreadHiding.hiddenThreads = hiddenThreads2; }, isHidden(boardID, threadID) { return !!(ThreadHiding.db && ThreadHiding.db.get({ boardID, threadID })); }, node() { let data; if (this.isReply || this.isClone || this.isFetchedQuote) { return; } if (Conf['Thread Hiding Buttons']) { $.prepend(this.nodes.root, ThreadHiding.makeButton(this.thread, 'hide')); } if (data = ThreadHiding.db.get({ boardID: this.board.ID, threadID: this.ID })) { ThreadHiding.hide(this.thread, data.makeStub, 'Hidden manually'); } }, onIndexRefresh() { return g.BOARD.threads.forEach(function (thread) { const { root } = thread.nodes; if (thread.isHidden && thread.stub && !root.contains(thread.stub)) { ThreadHiding.makeStub(thread, root); } }); }, menu: { thread: null, init() { if ((g.VIEW !== 'index') || !Conf['Menu'] || !Conf['Thread Hiding Link']) { return; } let div = $.el('div', { className: 'hide-thread-link', textContent: 'Hide' }); const apply = $.el('a', { textContent: 'Apply', href: 'javascript:;' }); $.on(apply, 'click', ThreadHiding.menu.hide); const makeStub = UI.checkbox('Stubs', 'Make stub'); Menu.menu.addEntry({ el: div, order: 20, open({ thread, isReply }) { if (isReply || thread.isHidden || (Conf['JSON Index'] && (Conf['Index Mode'] === 'catalog'))) { return false; } ThreadHiding.menu.thread = thread; return true; }, subEntries: [{ el: apply }, { el: makeStub }] }); div = $.el('a', { className: 'show-thread-link', textContent: 'Show', href: 'javascript:;' }); $.on(div, 'click', ThreadHiding.menu.show); Menu.menu.addEntry({ el: div, order: 20, open({ thread, isReply }) { if (isReply || !thread.isHidden || (Conf['JSON Index'] && (Conf['Index Mode'] === 'catalog'))) { return false; } ThreadHiding.menu.thread = thread; return true; } }); const hideStubLink = $.el('a', { textContent: 'Hide stub', href: 'javascript:;' }); $.on(hideStubLink, 'click', ThreadHiding.menu.hideStub); return Menu.menu.addEntry({ el: hideStubLink, order: 15, open({ thread, isReply }) { if (isReply || !thread.isHidden || (Conf['JSON Index'] && (Conf['Index Mode'] === 'catalog'))) { return false; } return ThreadHiding.menu.thread = thread; } }); }, hide() { const makeStub = $('input', this.parentNode).checked; const { thread } = ThreadHiding.menu; ThreadHiding.hide(thread, makeStub, 'Hidden manually'); ThreadHiding.saveHiddenState(thread, makeStub); return $.event('CloseMenu'); }, show() { const { thread } = ThreadHiding.menu; ThreadHiding.show(thread); ThreadHiding.saveHiddenState(thread); return $.event('CloseMenu'); }, hideStub() { const { thread } = ThreadHiding.menu; ThreadHiding.show(thread); ThreadHiding.hide(thread, false); ThreadHiding.saveHiddenState(thread, false); $.event('CloseMenu'); } }, makeButton(thread, type) { const span = $.el('span', { className: 'stub-icon', }); const a = $.el('a', { className: `${type}-post-button ${type}-thread-button`, href: 'javascript:;' }); Icon.set(span, type === 'hide' ? 'squareMinus' : 'squarePlus'); $.add(a, span); a.dataset.fullID = thread.fullID; $.on(a, 'click', ThreadHiding.toggle); return a; }, makeStub(thread, root, reason) { let summary, threadDivider; let numReplies = $$(g.SITE.selectors.replyOriginal, root).length; if (summary = $(g.SITE.selectors.summary, root)) { numReplies += +(summary.textContent?.match(/\d+/)?.[0] || 0); } const a = ThreadHiding.makeButton(thread, 'show'); const { nameBlock, subject } = thread.OP.info; if (subject) { $.add(a, $.el('span', { className: 'stub-subject', textContent: subject })); } $.add(a, $.el('span', { className: 'stub-name', textContent: nameBlock })); $.add(a, $.el('span', { className: 'stub-replies', textContent: `(${numReplies} repl${numReplies === 1 ? 'y' : 'ies'})` })); let reasons = thread.OP.filterResults?.reasons || []; if (reason) reasons = [...reasons, reason]; if (Conf['Filter Reason'] && reasons.length) { const reasonsSpan = $.el('span', { className: 'stub-reasons' }); $.add(reasonsSpan, reasons.map((re) => $.el('span', { className: 'stub-reason', textContent: re }))); a.appendChild(reasonsSpan); } thread.stub = $.el('div', { className: 'stub' }); if (Conf['Menu']) { $.add(thread.stub, [a, Menu.makeButton(thread.OP)]); } else { $.add(thread.stub, a); } if (!Conf['Filter Reason'] && reasons) thread.stub.title = reasons.join(' & '); $.prepend(root, thread.stub); if (threadDivider = $(g.SITE.selectors.threadDivider, root)) { return $.addClass(threadDivider, 'threadDivider'); } }, saveHiddenState(thread, makeStub) { if (thread.isHidden) { ThreadHiding.db.set({ boardID: thread.board.ID, threadID: thread.ID, val: { makeStub } }); UndoStack.record('Restored hidden thread', () => { ThreadHiding.show(thread); ThreadHiding.saveHiddenState(thread); }); } else { ThreadHiding.db.delete({ boardID: thread.board.ID, threadID: thread.ID }); } return ThreadHiding.catalogSet(thread.board); }, toggle(thread) { if (!(thread instanceof Thread)) { thread = g.threads.get(this.dataset.fullID); } if (thread.isHidden) { ThreadHiding.show(thread); } else { ThreadHiding.hide(thread, undefined, 'Hidden manually'); } return ThreadHiding.saveHiddenState(thread); }, hide(thread, makeStub = Conf['Stubs'], reason) { if (thread.isHidden) { return; } const threadRoot = thread.nodes.root; thread.isHidden = true; Index.updateHideLabel(); if (thread.catalogView && !Index.showHiddenThreads) { $.rm(thread.catalogView.nodes.root); $.event('PostsRemoved', null, Index.root); } if (!makeStub) { return threadRoot.hidden = true; } ThreadHiding.makeStub(thread, threadRoot, reason); }, show(thread) { if (thread.stub) { $.rm(thread.stub); delete thread.stub; } const threadRoot = thread.nodes.root; threadRoot.hidden = (thread.isHidden = false); Index.updateHideLabel(); if (thread.catalogView && Conf['Index Mode'] === 'catalog') { const { root } = thread.catalogView.nodes; if (Index.showHiddenThreads) { $.rm(root); $.event('PostsRemoved', null, Index.root); } else { let i = Index.sortedThreadIDs.indexOf(thread.ID) - 1; while (true) { if (i < 0) { $('.board').insertAdjacentElement('afterbegin', root); break; } const rootPrevious = d.getElementById(`t${Index.sortedThreadIDs[i]}`); if (rootPrevious) { rootPrevious.insertAdjacentElement('afterend', root); break; } --i; } $.event('PostsInserted', null, Index.root); } } } }; const DownloadAll = { busy: false, dialog: null, catalogItems: null, catalogFetching: false, init() { if (!(Conf['Download All Media'] && (g.VIEW === 'thread' || g.VIEW === 'index' || g.VIEW === 'catalog'))) return; const el = $.el('a', { href: 'javascript:;', title: 'Download all media', }); Icon.set(el, 'download', 'Download all media'); $.on(el, 'click', DownloadAll.cb.open); Header.addShortcut('download-all', el, 525); const onPostsReady = () => { if (DownloadAll.dialog && !DownloadAll.dialog.hidden) DownloadAll.refreshDialog(); }; $.on(d, '4chanXInitFinished PostsInserted ThreadUpdate IndexRefresh', onPostsReady); $.on(d, 'MobileSheetOpened', (e) => { if (e.detail !== 'download-all' && DownloadAll.dialog && !DownloadAll.dialog.hidden) { DownloadAll.hide(); } }); if (Conf['Persistent Download Media']) DownloadAll.show(); }, menu: { init() { if (!(Conf['Download All Media'] && (g.VIEW === 'thread' || g.VIEW === 'index' || g.VIEW === 'catalog'))) return; const el = $.el('span', { textContent: 'Download Media', className: 'download-all-link', }); el.dataset.nextKey = 'Download All Media'; Header.menu.addEntry({ el, order: 106, subEntries: DownloadAll.menu.createSubEntries(), }); }, createSubEntries() { const all = $.el('a', { href: 'javascript:;', textContent: 'Download all media' }); $.on(all, 'click', () => DownloadAll.start('all')); const images = $.el('a', { href: 'javascript:;', textContent: 'Download images only' }); $.on(images, 'click', () => DownloadAll.start('image')); const videos = $.el('a', { href: 'javascript:;', textContent: 'Download videos only' }); $.on(videos, 'click', () => DownloadAll.start('video')); return [ { el: all }, { el: images }, { el: videos }, ]; }, }, cb: { open(e) { if (e) e.preventDefault(); DownloadAll.toggle(); }, }, toggle() { if (DownloadAll.dialog && !DownloadAll.dialog.hidden) { DownloadAll.hide(); } else { DownloadAll.show(); } }, hide() { if (DownloadAll.dialog) DownloadAll.dialog.hidden = true; }, show() { if (!DownloadAll.dialog) DownloadAll.buildDialog(); DownloadAll.dialog.hidden = false; if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { $.event('MobileSheetOpened', 'download-all'); } DownloadAll.refreshDialog(); if (g.VIEW === 'catalog' && !DownloadAll.catalogItems) { DownloadAll.fetchCatalog(() => DownloadAll.refreshDialog()); } }, buildDialog() { const dialog = $.el('div', { id: 'download-all-picker', className: 'dialog' }); dialog.style.cssText = Conf['download-all-picker.position'] || ''; dialog.innerHTML = '
' + '' + '' + Icon.get('xmark') + '' + '
' + '
' + '
' + '' + '' + '' + '
' + '
Downloads are bundled into a single ZIP archive.
' + '
'; const persistBox = $('input[name="Persistent Download Media"]', dialog); persistBox.checked = !!Conf['Persistent Download Media']; $.on(persistBox, 'change', $.cb.checked); $.on(persistBox, 'mousedown touchstart', (e) => e.stopPropagation()); const closeBtn = $('.close', dialog); $.on(closeBtn, 'click', () => DownloadAll.hide()); $.on(closeBtn, 'mousedown touchstart', (e) => e.stopPropagation()); const moveHandle = $('.move', dialog); $.on(moveHandle, 'mousedown touchstart', dragstart); for (const btn of $$('button[data-filter]', dialog)) { $.on(btn, 'click', () => { const filter = btn.dataset.filter; DownloadAll.start(filter); }); } $.add(d.body, dialog); DownloadAll.dialog = dialog; }, refreshDialog() { if (!DownloadAll.dialog) return; const items = DownloadAll.collect('all'); const stats = { all: { count: 0, sizeInBytes: 0, unknownSizeCount: 0 }, image: { count: 0, sizeInBytes: 0, unknownSizeCount: 0 }, video: { count: 0, sizeInBytes: 0, unknownSizeCount: 0 }, }; for (const item of items) { const hasKnownSize = Number.isFinite(item.sizeInBytes); const add = (kind) => { const stat = stats[kind]; stat.count++; if (hasKnownSize) { stat.sizeInBytes += item.sizeInBytes; } else { stat.unknownSizeCount++; } }; if (item.isImage) add('image'); if (item.isVideo) add('video'); if (item.isImage || item.isVideo) add('all'); } for (const btn of $$('button[data-filter]', DownloadAll.dialog)) { const k = btn.dataset.filter; const { count, sizeInBytes, unknownSizeCount } = stats[k]; const label = k === 'all' ? 'All' : k === 'image' ? 'Images' : 'Videos'; btn.innerHTML = `${label}` + `${count} ${count === 1 ? 'file' : 'files'}` + `${formatAggregateSize(sizeInBytes, unknownSizeCount)}`; btn.disabled = count === 0; } }, start(filter) { if (DownloadAll.busy) { new Notice('warning', 'A download is already in progress.', 5); return; } if (g.VIEW === 'catalog' && !DownloadAll.catalogItems) { DownloadAll.fetchCatalog(() => { DownloadAll.refreshDialog(); DownloadAll.start(filter); }); return; } const items = DownloadAll.collect(filter); if (!items.length) { new Notice('warning', `No matching media found.`, 5); return; } DownloadAll.busy = true; const progress = DownloadAll.makeProgress(items.length); const done = () => { DownloadAll.busy = false; progress.close(); }; DownloadAll.runZip(items, progress, done); }, fetchCatalog(cb) { if (DownloadAll.catalogFetching) return; const site = g.SITE; const board = g.BOARD; const url = site.urls.catalogJSON?.(board); if (!url) { DownloadAll.catalogItems = []; cb(); return; } DownloadAll.catalogFetching = true; $.ajax(url, { onloadend() { DownloadAll.catalogFetching = false; const items = []; if (this.status === 200 && Array.isArray(this.response)) { const seen = dict(); const usedNames = dict(); for (const page of this.response) { for (const data of (page.threads || [])) { if (!data || !data.ext) continue; let file; try { file = site.Build.parseJSONFile(data, { siteID: site.ID, boardID: board.ID }); } catch (e) { continue; } if (!file?.url || seen[file.url]) continue; seen[file.url] = true; const isImage = $.isImage(file.url); const isVideo = $.isVideo(file.url); if (!isImage && !isVideo) continue; items.push({ url: file.url, name: makeName(file.name || file.url, usedNames), isImage, isVideo, sizeInBytes: getSizeInBytes(file, data.fsize), }); } } } else { new Notice('warning', 'Failed to fetch catalog data.', 5); } DownloadAll.catalogItems = items; cb(); }, }); }, collect(filter) { if (g.VIEW === 'catalog') { const items = DownloadAll.catalogItems || []; return items.filter(i => filter === 'image' ? i.isImage : filter === 'video' ? i.isVideo : (i.isImage || i.isVideo)); } const seen = dict(); const out = []; const usedNames = dict(); for (const key of g.posts.keys) { const post = g.posts[key]; if (!post || post.isHidden) continue; for (const file of (post.files || [])) { if (!file || file.isDead || !file.url) continue; if (filter === 'image' && !file.isImage) continue; if (filter === 'video' && !file.isVideo) continue; if (filter === 'all' && !file.isImage && !file.isVideo) continue; if (seen[file.url]) continue; seen[file.url] = true; out.push({ url: file.url, name: makeName(file.name || file.url.split('/').pop() || 'file', usedNames), isImage: !!file.isImage, isVideo: !!file.isVideo, sizeInBytes: getSizeInBytes(file, file.sizeInBytes), }); } } return out; }, makeProgress(total) { const wrap = $.el('div', { className: 'da-progress' }); wrap.innerHTML = `Downloading 0 / ${total}… ` + `cancel`; const notice = new Notice('info', wrap, 0); let cancelled = false; const progress = { onCancel: null, update(done, failed) { const label = $('.da-progress-label', wrap); if (label) { label.textContent = failed ? `Downloading ${done} / ${total} (${failed} failed)…` : `Downloading ${done} / ${total}…`; } }, isCancelled: () => cancelled, close: () => notice.close(), }; $.on($('.da-progress-cancel', wrap), 'click', () => { cancelled = true; notice.close(); progress.onCancel?.(); }); return progress; }, runZip(items, progress, done) { const entries = []; let i = 0, failed = 0, finished = false; const finish = () => { if (finished) return; finished = true; done(); if (progress.isCancelled()) return; if (!entries.length) { new Notice('error', 'No files could be downloaded.', 10); return; } const blob = buildZip(entries); const url = URL.createObjectURL(blob); const a = $.el('a', { href: url, download: zipFileName(), hidden: true }); $.add(d.body, a); a.click(); $.rm(a); setTimeout(() => URL.revokeObjectURL(url), 60 * SECOND); new Notice('success', `Saved ZIP with ${entries.length} file(s)${failed ? `, ${failed} failed` : ''}.`, 5); }; progress.onCancel = finish; const step = () => { if (progress.isCancelled() || i >= items.length) { finish(); return; } const item = items[i++]; let settled = false; const advance = (data) => { if (settled || finished) return; settled = true; clearTimeout(watchdog); if (!data) { failed++; } else { entries.push({ name: item.name, data }); } progress.update(i, failed); step(); }; const watchdog = setTimeout(() => advance(null), 120 * SECOND); CrossOrigin.binary(item.url, (data) => advance(data)); }; step(); }, }; function makeName(raw, usedNames) { let name = raw.replace(/[\\/:*?"<>|\r\n]/g, '_'); if (usedNames[name] != null) { const dot = name.lastIndexOf('.'); const stem = dot > 0 ? name.slice(0, dot) : name; const ext = dot > 0 ? name.slice(dot) : ''; name = `${stem}_${++usedNames[name]}${ext}`; } usedNames[name] = usedNames[name] || 0; return name; } function zipFileName() { const parts = ['media', g.BOARD?.ID || 'thread']; if (g.THREADID) parts.push(String(g.THREADID)); return parts.join('-') + '.zip'; } function formatAggregateSize(sizeInBytes, unknownSizeCount) { const sizeLabel = formatDialogSize(sizeInBytes); if (unknownSizeCount <= 0) return sizeLabel; if (sizeInBytes > 0) return `${sizeLabel} + unknown`; return 'unknown'; } function formatDialogSize(sizeInBytes) { let unit = 0; let size = sizeInBytes; while (size >= 1024 && unit < 3) { size /= 1024; unit++; } const rounded = size >= 100 ? Math.round(size) : unit > 1 ? Math.round(size * 100) / 100 : Math.round(size); return `${rounded} ${['B', 'KB', 'MB', 'GB'][unit]}`; } function getSizeInBytes(file, preferred) { return normalizeSizeInBytes(preferred) ?? normalizeSizeInBytes(file.sizeInBytes) ?? normalizeSizeInBytes(file.size); } function normalizeSizeInBytes(raw) { if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw; if (typeof raw !== 'string') return null; return parseSizeString(raw); } function parseSizeString(sizeText) { const match = sizeText.trim().match(/^([\d.,]+)\s*([KMG]?B)$/i); if (!match) return null; const amount = parseFloat(match[1].replace(/,/g, '')); if (!Number.isFinite(amount) || amount < 0) return null; const unit = match[2].toUpperCase(); const power = ['B', 'KB', 'MB', 'GB'].indexOf(unit); if (power < 0) return null; return amount * (1024 ** power); } const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; })(); function crc32(buf) { let c = 0xFFFFFFFF; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; } function utf8(s) { return new TextEncoder().encode(s); } function dosDateTime(d) { const time = ((d.getHours() & 0x1F) << 11) | ((d.getMinutes() & 0x3F) << 5) | ((d.getSeconds() / 2) & 0x1F); const date = (((d.getFullYear() - 1980) & 0x7F) << 9) | (((d.getMonth() + 1) & 0x0F) << 5) | (d.getDate() & 0x1F); return { date, time }; } function buildZip(files) { const parts = []; const central = []; let offset = 0; const { date, time } = dosDateTime(new Date()); for (const f of files) { const nameBytes = utf8(f.name); const crc = crc32(f.data); const size = f.data.length; const lfh = new Uint8Array(30 + nameBytes.length); const dvLfh = new DataView(lfh.buffer); dvLfh.setUint32(0, 0x04034b50, true); dvLfh.setUint16(4, 20, true); dvLfh.setUint16(6, 0x0800, true); dvLfh.setUint16(8, 0, true); dvLfh.setUint16(10, time, true); dvLfh.setUint16(12, date, true); dvLfh.setUint32(14, crc, true); dvLfh.setUint32(18, size, true); dvLfh.setUint32(22, size, true); dvLfh.setUint16(26, nameBytes.length, true); dvLfh.setUint16(28, 0, true); lfh.set(nameBytes, 30); parts.push(lfh, f.data); const cdr = new Uint8Array(46 + nameBytes.length); const dvCdr = new DataView(cdr.buffer); dvCdr.setUint32(0, 0x02014b50, true); dvCdr.setUint16(4, 20, true); dvCdr.setUint16(6, 20, true); dvCdr.setUint16(8, 0x0800, true); dvCdr.setUint16(10, 0, true); dvCdr.setUint16(12, time, true); dvCdr.setUint16(14, date, true); dvCdr.setUint32(16, crc, true); dvCdr.setUint32(20, size, true); dvCdr.setUint32(24, size, true); dvCdr.setUint16(28, nameBytes.length, true); dvCdr.setUint16(30, 0, true); dvCdr.setUint16(32, 0, true); dvCdr.setUint16(34, 0, true); dvCdr.setUint16(36, 0, true); dvCdr.setUint32(38, 0, true); dvCdr.setUint32(42, offset, true); cdr.set(nameBytes, 46); central.push(cdr); offset += lfh.length + f.data.length; } const cdSize = central.reduce((n, c) => n + c.length, 0); const cdOffset = offset; const eocd = new Uint8Array(22); const dvEocd = new DataView(eocd.buffer); dvEocd.setUint32(0, 0x06054b50, true); dvEocd.setUint16(4, 0, true); dvEocd.setUint16(6, 0, true); dvEocd.setUint16(8, files.length, true); dvEocd.setUint16(10, files.length, true); dvEocd.setUint32(12, cdSize, true); dvEocd.setUint32(16, cdOffset, true); dvEocd.setUint16(20, 0, true); return new Blob([...parts, ...central, eocd], { type: 'application/zip' }); } var FappeTyme = { nodes: null, enabled: null, init() { if ((!Conf['Fappe Tyme'] && !Conf['Werk Tyme']) || (g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive')) { return; } this.nodes = {}; this.enabled = { fappe: false, werk: Conf['werk'] }; for (var type of ["Fappe", "Werk"]) { if (Conf[`${type} Tyme`]) { var lc = type.toLowerCase(); var el = UI.checkbox(lc, `${type} Tyme`, false); el.title = `${type} Tyme`; this.nodes[lc] = el.firstElementChild; if (Conf[lc]) { this.set(lc, true); } $.on(this.nodes[lc], 'change', this.toggle.bind(this, lc)); Header.menu.addEntry({ el, order: 97 }); var indicator = $.el('span', { className: 'indicator', textContent: type[0], title: `${type} Tyme active` }); $.on(indicator, 'click', function () { const check = $.getOwn(FappeTyme.nodes, this.parentNode.id.replace('shortcut-', '')); check.checked = !check.checked; return $.event('change', null, check); }); Header.addShortcut(lc, indicator, 410); } } if (Conf['Werk Tyme']) { $.sync('werk', this.set.bind(this, 'werk')); } Callbacks.Post.push({ name: 'Fappe Tyme', cb: this.node }); return Callbacks.CatalogThread.push({ name: 'Werk Tyme', cb: this.catalogNode }); }, node() { return this.nodes.root.classList.toggle('noFile', !this.files.length); }, catalogNode() { const file = this.thread.OP.files[0]; if (!file) { return; } const filename = $.el('div', { textContent: file.name, className: 'werkTyme-filename' }); return $.add(this.nodes.thumb.parentNode, filename); }, set(type, enabled) { this.enabled[type] = (this.nodes[type].checked = enabled); return $[`${enabled ? 'add' : 'rm'}Class`](doc, `${type}Tyme`); }, toggle(type) { this.set(type, !this.enabled[type]); if (type === 'werk') { return $.cb.checked.call(this.nodes[type]); } } }; var galleryPage = `
×
/
`; var Sauce = { link: null, links: null, init() { let link; if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Sauce']) { return; } $.addClass(doc, 'show-sauce'); const links = []; for (link of Conf['sauces'].split('\n')) { var linkData; if ((link[0] !== '#') && (linkData = this.parseLink(link))) { links.push(linkData); } } if (!links.length) { return; } this.links = links; this.link = $.el('a', { target: '_blank', className: 'sauce' }); if (Conf['Menu'] && resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { this.addMenuEntry(links); } return Callbacks.Post.push({ name: 'Sauce', cb: this.node }); }, addMenuEntry(links) { Menu.menu.addEntry({ el: $.el('div', { textContent: 'Sauce' }), order: 101, open(post) { if (!post.file) { return false; } return links.some(link => { const node = Sauce.createSauceLink(link, post, post.file); return node && !node.dataset.skip; }); }, subEntries: links.map(function (link) { const el = $.el('a', { target: '_blank' }); return { el, open(post) { if (!post.file) { return false; } const node = Sauce.createSauceLink(link, post, post.file); if (!node || node.dataset.skip) { return false; } el.href = node.href; el.textContent = node.textContent; if (node.hasAttribute('target')) { el.target = '_blank'; } else { el.removeAttribute('target'); } return true; } }; }) }); }, parseLink(link) { if (!(link = link.trim())) { return null; } const parts = dict(); const iterable = link.split(/;(?=(?:text|boards|types|regexp|sandbox):?)/); for (let i = 0; i < iterable.length; i++) { var part = iterable[i]; if (i === 0) { parts['url'] = part; } else { var m = part.match(/^(\w*):?(.*)$/); parts[m[1]] = m[2]; } } if (!parts['text']) { parts['text'] = parts['url'].match(/(\w+)\.\w+\//)?.[1] || '?'; } if ((parts['text'] || '').trim().toLowerCase() === 'wait' && /(?:^|\/\/)trace\.moe\//i.test(parts['url'] || '')) { parts['text'] = 'trace'; } if ('boards' in parts) { parts['boards'] = Filter.parseBoards(parts['boards']); } if ('regexp' in parts) { try { let regexp; if (regexp = parts['regexp'].match(/^\/(.*)\/(\w*)$/)) { parts['regexp'] = RegExp(regexp[1], regexp[2]); } else { parts['regexp'] = RegExp(parts['regexp']); } } catch (err) { new Notice('warning', [ $.tn("Invalid regexp for Sauce link:"), $.el('br'), $.tn(link), $.el('br'), $.tn(err instanceof Error ? err.message : String(err)) ], 60); return null; } } return parts; }, createSauceLink(link, post, file) { let a, needle; let matches = null; const ext = file.url.match(/[^.]*$/)[0]; const parts = dict(); $.extend(parts, link); if (!!parts['boards'] && !parts['boards'][`${post.siteID}/${post.boardID}`] && !parts['boards'][`${post.siteID}/*`]) { return null; } if (!!parts['types'] && (needle = ext, !parts['types'].split(',').includes(needle))) { return null; } if (!!parts['regexp'] && (!(matches = file.name.match(parts['regexp'])))) { return null; } const missing = []; for (var key of ['url', 'text']) { parts[key] = parts[key].replace(/%(T?URL|IMG|[sh]?MD5|board|name|%|semi|\$\d+)/g, function (orig, parameter) { let type; if (parameter[0] === '$') { if (!matches) { return orig; } type = matches[Number(parameter.slice(1))] || ''; } else { type = Sauce.formatters[parameter](post, file, ext); if ((type == null)) { missing.push(parameter); return ''; } } if ((key === 'url') && !['%', 'semi'].includes(parameter)) { if (/^javascript:/i.test(parts['url'])) { type = JSON.stringify(type); } type = encodeURIComponent(type); } return type; }); } if (g.SITE.areMD5sDeferred?.(post.board) && missing.length && !missing.filter(x => !/^.?MD5$/.test(x)).length) { a = Sauce.link.cloneNode(false); a.dataset.skip = '1'; return a; } if (missing.length) { return null; } a = Sauce.link.cloneNode(false); a.href = parts['url']; a.textContent = parts['text']; if (/^javascript:/i.test(parts['url'])) { a.removeAttribute('target'); } return a; }, node() { if (this.isClone) { return; } for (var file of this.files) { Sauce.file(this, file); } }, file(post, file) { let link, node; const nodes = []; const skipped = []; for (link of Sauce.links) { if (node = Sauce.createSauceLink(link, post, file)) { nodes.push($.tn(' '), node); if (node.dataset.skip) { skipped.push([link, node]); } } } if (!nodes.length) { return; } const container = $.el('span', { className: 'sauce-container' }); $.add(container, nodes); $.add(file.text, container); if (skipped.length) { var observer = new MutationObserver(function () { if (file.text.dataset.md5) { for ([link, node] of skipped) { var node2; if (node2 = Sauce.createSauceLink(link, post, file)) { $.replace(node, node2); } } return observer.disconnect(); } }); return observer.observe(file.text, { attributes: true }); } }, formatters: { TURL(post, file) { return file.thumbURL; }, URL(post, file) { return file.url; }, IMG(post, file, ext) { if (['gif', 'jpg', 'jpeg', 'png'].includes(ext)) { return file.url; } else { return file.thumbURL; } }, MD5(post, file) { return file.MD5; }, sMD5(post, file) { return file.MD5?.replace(/[+/=]/g, (c) => ({ '+': '-', '/': '_', '=': '' })[c]); }, hMD5(post, file) { if (file.MD5) { return Array.from(atob(file.MD5), c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(''); } }, board(post) { return post.board.ID; }, name(post, file) { return file.name; }, '%'() { return '%'; }, semi() { return ';'; } } }; var Gallery = { enabled: false, delay: 0, nodes: null, images: [], fileIDs: null, slideshow: false, cache: null, timeoutID: 0, fullscreen: false, swipe: null, colInput: null, colLabelText: null, init() { if (!(this.enabled = Conf['Gallery'] && (g.VIEW === 'index' || g.VIEW === 'thread'))) { return; } this.delay = Conf['Slide Delay']; const el = $.el('a', { href: 'javascript:;', title: 'Gallery', }); Icon.set(el, 'image', 'Gallery'); $.on(el, 'click', this.cb.toggle); Header.addShortcut('gallery', el, 530); return Callbacks.Post.push({ name: 'Gallery', cb: this.node }); }, node() { return (() => { const result = []; for (var file of this.files) { if (file.thumb) { if (Gallery.nodes) { Gallery.generateThumb(this, file); Gallery.nodes.total.textContent = Gallery.images.length; } if (!Conf['Image Expansion'] && ((g.SITE.software !== 'tinyboard') || !Main.jsEnabled)) { result.push($.on(file.thumbLink, 'click', Gallery.cb.image)); } else { result.push(undefined); } } } return result; })(); }, build(image) { let dialog, thumb; const { cb } = Gallery; if (Conf['Fullscreen Gallery']) { $.one(d, 'fullscreenchange mozfullscreenchange webkitfullscreenchange', () => $.on(d, 'fullscreenchange mozfullscreenchange webkitfullscreenchange', cb.close)); doc.mozRequestFullScreen?.(); doc.webkitRequestFullScreen?.(Element.ALLOW_KEYBOARD_INPUT); } Gallery.images = []; const nodes = (Gallery.nodes = {}); Gallery.fileIDs = dict(); Gallery.slideshow = false; nodes.el = (dialog = $.el('div', { id: 'a-gallery' })); $.extend(dialog, { innerHTML: galleryPage }); const object = { buttons: '.gal-buttons', frame: '.gal-image', name: '.gal-name', count: '.count', total: '.total', sauce: '.gal-sauce', thumbs: '.gal-thumbnails', next: '.gal-image a', current: '.gal-image img' }; for (var key in object) { var value = object[key]; nodes[key] = $(value, dialog); } const menuButton = $('.menu-button', dialog); nodes.menu = new UI.Menu('gallery'); $.on(nodes.frame, 'click', cb.blank); $.on(nodes.frame, 'pointerdown', cb.swipeStart); $.on(nodes.frame, 'pointerup', cb.swipeEnd); $.on(nodes.frame, 'pointercancel', cb.swipeCancel); if (Conf['Mouse Wheel Volume']) { $.on(nodes.frame, 'wheel', Volume.wheel); } $.on(nodes.next, 'click', cb.click); $.on(nodes.name, 'click', ImageCommon.download); $.on(nodes.thumbs, 'click', cb.thumbsBlank); const prev = $('.gal-prev', dialog); const next = $('.gal-next', dialog); const start = $('.gal-start', dialog); const stop = $('.gal-stop', dialog); const close = $('.gal-close', dialog); $.on(prev, 'click', cb.prev); $.on(next, 'click', cb.next); $.on(start, 'click', cb.start); $.on(stop, 'click', cb.stop); $.on(close, 'click', cb.close); $.on(menuButton, 'click', function (e) { return nodes.menu.toggle(e, this, g); }); Icon.set(menuButton, 'caretDown'); Icon.set(start, 'play'); Icon.set(stop, 'stop'); Icon.set(close, 'xmark'); Icon.set(prev, 'caretLeft'); Icon.set(next, 'caretRight'); for (var entry of Gallery.menu.createSubEntries()) { entry.order = 0; nodes.menu.addEntry(entry); } Gallery.cb.setLayout(); $.on(d, 'keydown', cb.keybinds); if (Conf['Keybinds']) { $.off(d, 'keydown', Keybinds.keydown); } $.on(window, 'resize', Gallery.cb.setHeight); $.on(window, 'resize', Gallery.cb.setLayout); for (var postThumb of $$(g.SITE.selectors.file.thumb)) { var post; if (!(post = Get.postFromNode(postThumb))) { continue; } for (var file of post.files) { if (file.thumb) { Gallery.generateThumb(post, file); if (!image && Gallery.fileIDs[`${post.fullID}.${file.index}`]) { var candidate = file.thumbLink; if ((Header.getTopOf(candidate) + candidate.getBoundingClientRect().height) >= 0) { image = candidate; } } } } } $.addClass(doc, 'gallery-open'); $.add(d.body, dialog); nodes.thumbs.scrollTop = 0; nodes.current.parentElement.scrollTop = 0; if (image) { thumb = $(`[href='${image.href}']`, nodes.thumbs); } if (!thumb) { thumb = Gallery.images[Gallery.images.length - 1]; } if (thumb) { Gallery.open(thumb); } doc.style.overflow = 'hidden'; return nodes.total.textContent = Gallery.images.length; }, generateThumb(post, file) { if (post.isClone || post.isHidden) { return; } if (!file || !file.thumb || (!file.isImage && !file.isVideo && !Conf['PDF in Gallery'])) { return; } if (Gallery.fileIDs[`${post.fullID}.${file.index}`]) { return; } Gallery.fileIDs[`${post.fullID}.${file.index}`] = true; const thumb = $.el('a', { className: 'gal-thumb', href: file.url, target: '_blank', title: file.name }); thumb.dataset.id = Gallery.images.length; thumb.dataset.post = post.fullID; thumb.dataset.file = file.index; const thumbImg = file.thumb.cloneNode(false); thumbImg.style.cssText = ''; $.add(thumb, thumbImg); $.on(thumb, 'click', Gallery.cb.open); Gallery.images.push(thumb); return $.add(Gallery.nodes.thumbs, thumb); }, load(thumb, errorCB) { const ext = thumb.href.match(/\w*$/); const elType = $.getOwn({ 'webm': 'video', 'mp4': 'video', 'ogv': 'video', 'pdf': 'iframe' }, ext) || 'img'; const file = $.el(elType); $.extend(file.dataset, thumb.dataset); $.on(file, 'error', errorCB); file.src = thumb.href; return file; }, open(thumb) { let el, file, post; const { nodes } = Gallery; const oldID = +nodes.current.dataset.id; const newID = +thumb.dataset.id; if (el = Gallery.images[oldID]) { $.rmClass(el, 'gal-highlight'); } $.addClass(thumb, 'gal-highlight'); nodes.thumbs.scrollTop = (thumb.offsetTop + (thumb.offsetHeight / 2)) - (nodes.thumbs.clientHeight / 2); nodes.thumbs.scrollLeft = (thumb.offsetLeft + (thumb.offsetWidth / 2)) - (nodes.thumbs.clientWidth / 2); if (Gallery.cache?.dataset.id === ('' + newID)) { file = Gallery.cache; $.off(file, 'error', Gallery.cacheError); $.on(file, 'error', Gallery.error); } else { file = Gallery.load(thumb, Gallery.error); } $.off(nodes.current, 'error', Gallery.error); ImageCommon.pause(nodes.current); $.replace(nodes.current, file); nodes.current = file; if (file.nodeName === 'VIDEO') { file.loop = true; Volume.setup(file); if (Conf['Autoplay']) { file.play(); } if (Conf['Show Controls']) file.controls = true; } doc.classList.toggle('gal-pdf', file.nodeName === 'IFRAME'); Gallery.cb.setHeight(); nodes.count.textContent = +thumb.dataset.id + 1; nodes.name.download = (nodes.name.textContent = thumb.title); nodes.name.href = thumb.href; nodes.frame.scrollTop = 0; nodes.next.focus(); $.rmAll(nodes.sauce); if (Conf['Sauce'] && Sauce.links && (post = g.posts.get(file.dataset.post))) { const sauces = []; for (var link of Sauce.links) { var node; if (node = Sauce.createSauceLink(link, post, post.files[+file.dataset.file])) { sauces.push($.tn(' '), node); } } $.add(nodes.sauce, sauces); } if (Gallery.slideshow && ((newID > oldID) || ((oldID === (Gallery.images.length - 1)) && (newID === 0)))) { Gallery.setupTimer(); } else { Gallery.cb.stop(); } if (Conf['Scroll to Post'] && (post = g.posts.get(file.dataset.post))) { Header.scrollTo(post.nodes.root); } if (isNaN(oldID) || (newID === ((oldID + 1) % Gallery.images.length))) { return Gallery.cache = Gallery.load(Gallery.images[(newID + 1) % Gallery.images.length], Gallery.cacheError); } }, error() { if (this.error?.code === MediaError.MEDIA_ERR_DECODE) { return new Notice('error', 'Corrupt or unplayable video', 30); } if (ImageCommon.isFromArchive(this)) { return; } const post = g.posts.get(this.dataset.post); const file = post.files[+this.dataset.file]; return ImageCommon.error(this, post, file, null, (url) => { if (!url) { return; } Gallery.images[+this.dataset.id].href = url; if (Gallery.nodes.current === this) { return this.src = url; } }); }, cacheError() { return delete Gallery.cache; }, cleanupTimer() { clearTimeout(Gallery.timeoutID); const { current } = Gallery.nodes; $.off(current, 'canplaythrough load', Gallery.startTimer); return $.off(current, 'ended', Gallery.cb.next); }, startTimer() { return Gallery.timeoutID = window.setTimeout(Gallery.checkTimer, Gallery.delay * SECOND); }, setupTimer() { Gallery.cleanupTimer(); const { current } = Gallery.nodes; const isVideo = current.nodeName === 'VIDEO'; if (isVideo) { current.play(); } if ((isVideo ? current.readyState >= 4 : current.complete) || (current.nodeName === 'IFRAME')) { return Gallery.startTimer(); } else { return $.on(current, (isVideo ? 'canplaythrough' : 'load'), Gallery.startTimer); } }, checkTimer() { const { current } = Gallery.nodes; if ((current.nodeName === 'VIDEO') && !current.paused) { $.on(current, 'ended', Gallery.cb.next); return current.loop = false; } else { return Gallery.cb.next(); } }, cb: { keybinds(e) { let key; if (!(key = Keybinds.keyCode(e))) { return; } const cb = (() => { switch (key) { case Conf['Close']: case Conf['Open Gallery']: return (Gallery.fullscreen && doc.classList.contains('gal-lightbox-open')) ? Gallery.cb.closeLightbox : Gallery.cb.close; case Conf['Next Gallery Image']: return Gallery.cb.next; case Conf['Advance Gallery']: return Gallery.cb.advance; case Conf['Previous Gallery Image']: return Gallery.cb.prev; case Conf['Pause']: return Gallery.cb.pause; case Conf['Slideshow']: return Gallery.cb.toggleSlideshow; case Conf['Rotate image anticlockwise']: return Gallery.cb.rotateLeft; case Conf['Rotate image clockwise']: return Gallery.cb.rotateRight; case Conf['Download Gallery Image']: return Gallery.cb.download; } })(); if (!cb) { return; } e.stopPropagation(); e.preventDefault(); return cb(); }, open(e) { if (e) { e.preventDefault(); } if (Gallery.fullscreen) { $.addClass(doc, 'gal-lightbox-open'); } if (this) { return Gallery.open(this); } }, closeLightbox() { return $.rmClass(doc, 'gal-lightbox-open'); }, image(e) { e.preventDefault(); e.stopPropagation(); return Gallery.build(this); }, prev() { return Gallery.cb.open.call(Gallery.images[+Gallery.nodes.current.dataset.id - 1] || Gallery.images[Gallery.images.length - 1], undefined); }, next() { return Gallery.cb.open.call(Gallery.images[+Gallery.nodes.current.dataset.id + 1] || Gallery.images[0], undefined); }, click(e) { if (ImageCommon.onControls(e)) { return; } e.preventDefault(); return Gallery.cb.advance(); }, advance() { if (!Conf['Autoplay'] && Gallery.nodes.current.paused) { return Gallery.nodes.current.play(); } else { return Gallery.cb.next(); } }, swipeStart(e) { if (e.pointerType !== 'touch' || !doc.classList.contains('xt-mobile')) { return; } Gallery.swipe = { x: e.clientX, y: e.clientY, id: e.pointerId }; }, swipeCancel() { Gallery.swipe = null; }, swipeEnd(e) { const { swipe } = Gallery; if (!swipe || (e.pointerId !== swipe.id)) { return; } Gallery.swipe = null; const dx = e.clientX - swipe.x; const dy = e.clientY - swipe.y; if ((Math.abs(dx) < 50) || (Math.abs(dx) < (Math.abs(dy) * 1.5))) { return; } return dx < 0 ? Gallery.cb.next() : Gallery.cb.prev(); }, toggle() { return (Gallery.nodes ? Gallery.cb.close : Gallery.build)(); }, blank(e) { if (e.target !== this) { return; } if (Gallery.fullscreen && doc.classList.contains('gal-lightbox-open')) { return Gallery.cb.closeLightbox(); } return Gallery.cb.close(); }, thumbsBlank(e) { if (!Gallery.fullscreen || (e.target !== this)) { return; } const last = Gallery.images[Gallery.images.length - 1]; if (last && (e.clientY > (last.getBoundingClientRect().bottom + 70))) { return Gallery.cb.close(); } }, toggleSlideshow() { return Gallery.cb[Gallery.slideshow ? 'stop' : 'start'](); }, download() { const name = $('.gal-name'); return name.click(); }, pause() { Gallery.cb.stop(); const { current } = Gallery.nodes; if (current.nodeName === 'VIDEO') { return current[current.paused ? 'play' : 'pause'](); } }, start() { $.addClass(Gallery.nodes.buttons, 'gal-playing'); Gallery.slideshow = true; return Gallery.setupTimer(); }, stop() { if (!Gallery.slideshow) { return; } Gallery.cleanupTimer(); const { current } = Gallery.nodes; if (current.nodeName === 'VIDEO') { current.loop = true; } $.rmClass(Gallery.nodes.buttons, 'gal-playing'); return Gallery.slideshow = false; }, rotateLeft() { return Gallery.cb.rotate(270); }, rotateRight() { return Gallery.cb.rotate(90); }, rotate: debounce(100, function (delta) { const { current } = Gallery.nodes; if (current.nodeName === 'IFRAME') { return; } current.dataRotate = ((current.dataRotate || 0) + delta) % 360; current.style.transform = `rotate(${current.dataRotate}deg)`; return Gallery.cb.setHeight(); }), close() { $.off(Gallery.nodes.current, 'error', Gallery.error); ImageCommon.pause(Gallery.nodes.current); $.rm(Gallery.nodes.el); $.rmClass(doc, 'gallery-open'); if (Conf['Fullscreen Gallery']) { $.off(d, 'fullscreenchange mozfullscreenchange webkitfullscreenchange', Gallery.cb.close); d.mozCancelFullScreen?.(); d.webkitExitFullscreen?.(); } delete Gallery.nodes; delete Gallery.fileIDs; delete Gallery.colInput; delete Gallery.colLabelText; Gallery.fullscreen = false; $.rmClass(doc, 'gal-lightbox-open'); for (var p of Gallery.cb.positions) { $.rmClass(doc, `gal-thumbs-${p}`); } doc.style.overflow = ''; $.off(d, 'keydown', Gallery.cb.keybinds); if (Conf['Keybinds']) { $.on(d, 'keydown', Keybinds.keydown); } $.off(window, 'resize', Gallery.cb.setHeight); $.off(window, 'resize', Gallery.cb.setLayout); return clearTimeout(Gallery.timeoutID); }, setFitness() { return (this.checked ? $.addClass : $.rmClass)(doc, `gal-${this.name.toLowerCase().replace(/\s+/g, '-')}`); }, positions: ['top', 'bottom', 'left', 'right'], setLayout() { const THUMB_CELL = 131; const MAX_EXTENT = 0.75; const cols = Math.max(0, parseInt(Conf['Gallery Columns'], 10) || 0); const hidden = Conf['Hide Thumbnails']; let grid = Conf['Grid Thumbnails'] && !hidden; let pos = Gallery.cb.positions.includes(Conf['Gallery Thumbnails Position']) ? Conf['Gallery Thumbnails Position'] : 'right'; const mobile = doc.classList.contains('xt-mobile'); if (mobile && !(grid && (cols === 0))) { grid = false; pos = 'bottom'; } const horizontal = (pos === 'top') || (pos === 'bottom'); for (var p of Gallery.cb.positions) { doc.classList.toggle(`gal-thumbs-${p}`, p === pos); } if (Gallery.colLabelText) { Gallery.colLabelText.nodeValue = horizontal ? 'Grid Rows: ' : 'Grid Columns: '; } Gallery.fullscreen = grid && (cols === 0); doc.classList.toggle('gal-fullscreen-thumbs', Gallery.fullscreen); if (!Gallery.fullscreen) { doc.classList.remove('gal-lightbox-open'); } const axis = horizontal ? doc.clientHeight : doc.clientWidth; const maxCols = Math.max(1, Math.floor(((MAX_EXTENT * axis) - 8) / THUMB_CELL)); if (Gallery.colInput) { Gallery.colInput.max = maxCols; } let effCols = cols, extent; if (hidden || Gallery.fullscreen) { extent = 0; } else if (grid) { effCols = Math.min(cols, maxCols); extent = (effCols * THUMB_CELL) + 8; } else { extent = mobile ? 96 : 150; } doc.style.setProperty('--gal-cols', String(effCols || 1)); return doc.style.setProperty('--gal-thumbs-width', `${extent}px`); }, clampColumns() { const max = parseInt(this.max, 10); let v = parseInt(this.value, 10); if (isNaN(v)) { return; } v = Math.max(0, v); if (max && (v > max)) { v = max; } this.value = v; }, setHeight: debounce(100, function () { let dim, margin, minHeight; const { current, frame } = Gallery.nodes; const { style } = current; if (Conf['Stretch to Fit'] && (dim = g.posts.get(current.dataset.post)?.files[+current.dataset.file].dimensions)) { const [width, height] = dim.split('x'); let containerWidth = frame.clientWidth; let containerHeight = doc.clientHeight - 25; if (((current.dataRotate || 0) % 180) === 90) { [containerWidth, containerHeight] = [containerHeight, containerWidth]; } minHeight = Math.min(containerHeight, (height / width) * containerWidth); style.minHeight = minHeight + 'px'; style.minWidth = ((width / height) * minHeight) + 'px'; } else { style.minHeight = (style.minWidth = ''); } if (((current.dataRotate || 0) % 180) === 90) { style.maxWidth = Conf['Fit Height'] ? `${doc.clientHeight - 25}px` : 'none'; style.maxHeight = Conf['Fit Width'] ? `${frame.clientWidth}px` : 'none'; margin = (current.clientWidth - current.clientHeight) / 2; return style.margin = `${margin}px ${-margin}px`; } else { return style.maxWidth = (style.maxHeight = (style.margin = '')); } }), setDelay() { return Gallery.delay = +this.value; } }, menu: { init() { if (!Gallery.enabled) { return; } const el = $.el('span', { textContent: 'Gallery', className: 'gallery-link' }); return Header.menu.addEntry({ el, order: 105, subEntries: Gallery.menu.createSubEntries() }); }, createSubEntry(name) { const label = UI.checkbox(name, name); const input = label.firstElementChild; if (['Hide Thumbnails', 'Fit Width', 'Fit Height'].includes(name)) { $.on(input, 'change', Gallery.cb.setFitness); } $.event('change', null, input); $.on(input, 'change', $.cb.checked); if (['Hide Thumbnails'].includes(name)) { $.on(input, 'change', Gallery.cb.setLayout); } if (['Hide Thumbnails', 'Fit Width', 'Fit Height', 'Stretch to Fit'].includes(name)) { $.on(input, 'change', Gallery.cb.setHeight); } return { el: label }; }, createSubEntries() { const subEntries = (['Hide Thumbnails', 'Fit Width', 'Fit Height', 'Stretch to Fit', 'Scroll to Post'].map((item) => Gallery.menu.createSubEntry(item))); const gridRow = $.el('span', { className: 'gal-grid-entry' }); const gridCheck = UI.checkbox('Grid Thumbnails', ''); const gridInput = gridCheck.firstElementChild; $.on(gridInput, 'change', Gallery.cb.setFitness); $.event('change', null, gridInput); $.on(gridInput, 'change', $.cb.checked); $.on(gridInput, 'change', Gallery.cb.setLayout); const colLabel = $.el('label', { title: '0 disables the image preview and shows fullscreen thumbnails.', innerHTML: 'Grid Columns: ' }); const colInput = colLabel.firstElementChild; colInput.value = String(Math.max(0, parseInt(Conf['Gallery Columns'], 10) || 0)); Gallery.colInput = colInput; Gallery.colLabelText = colLabel.firstChild; $.on(colInput, 'change', Gallery.cb.clampColumns); $.on(colInput, 'change', $.cb.value); $.on(colInput, 'change', Gallery.cb.setLayout); $.add(gridRow, [gridCheck, colLabel]); subEntries.push({ el: gridRow }); const posOptions = Gallery.cb.positions.map(p => ``).join(''); const posLabel = $.el('label', { innerHTML: `Thumbnails Position: ` }); const posInput = posLabel.firstElementChild; posInput.value = Gallery.cb.positions.includes(Conf['Gallery Thumbnails Position']) ? Conf['Gallery Thumbnails Position'] : 'right'; $.on(posInput, 'change', $.cb.value); $.on(posInput, 'change', Gallery.cb.setLayout); subEntries.push({ el: posLabel }); const delayLabel = $.el('label', { innerHTML: 'Slide Delay: ' }); const delayInput = delayLabel.firstElementChild; delayInput.value = String(Gallery.delay); $.on(delayInput, 'change', Gallery.cb.setDelay); $.on(delayInput, 'change', $.cb.value); subEntries.push({ el: delayLabel }); return subEntries; } } }; var Beep = 'UklGRjQDAABXQVZFZm10IBAAAAABAAEAgD4AAIA+AAABAAgAc21wbDwAAABBAAADAAAAAAAAAAA8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABkYXRhzAIAAGMms8em0tleMV4zIpLVo8nhfSlcPR102Ki+5JspVEkdVtKzs+K1NEhUIT7DwKrcy0g6WygsrM2k1NpiLl0zIY/WpMrjgCdbPhxw2Kq+5Z4qUkkdU9K1s+K5NkVTITzBwqnczko3WikrqM+l1NxlLF0zIIvXpsnjgydZPhxs2ay95aIrUEkdUdC3suK8N0NUIjq+xKrcz002WioppdGm091pK1w0IIjYp8jkhydXPxxq2K295aUrTkoeTs65suK+OUFUIzi7xqrb0VA0WSoootKm0t5tKlo1H4TYqMfkiydWQBxm16+85actTEseS8y7seHAPD9TIza5yKra01QyWSson9On0d5wKVk2H4DYqcfkjidUQB1j1rG75KsvSkseScu8seDCPz1TJDW2yara1FYxWSwnm9Sn0N9zKVg2H33ZqsXkkihSQR1g1bK65K0wSEsfR8i+seDEQTxUJTOzy6rY1VowWC0mmNWoz993KVc3H3rYq8TklSlRQh1d1LS647AyR0wgRMbAsN/GRDpTJTKwzKrX1l4vVy4lldWpzt97KVY4IXbUr8LZljVPRCxhw7W3z6ZISkw1VK+4sMWvXEhSPk6buay9sm5JVkZNiLWqtrJ+TldNTnquqbCwilZXU1BwpKirrpNgWFhTaZmnpquZbFlbVmWOpaOonHZcXlljhaGhpZ1+YWBdYn2cn6GdhmdhYGN3lp2enIttY2Jjco+bnJuOdGZlZXCImJqakHpoZ2Zug5WYmZJ/bGlobX6RlpeSg3BqaW16jZSVkoZ0bGtteImSk5KIeG5tbnaFkJKRinxxbm91gY2QkIt/c3BwdH6Kj4+LgnZxcXR8iI2OjIR5c3J0e4WLjYuFe3VzdHmCioyLhn52dHR5gIiKioeAeHV1eH+GiYqHgXp2dnh9hIiJh4J8eHd4fIKHiIeDfXl4eHyBhoeHhH96eHmA'; const BUILTIN_DEFAULT_ID = 'builtin:default'; const SoundManager = { db: undefined, threadUpdateHooked: false, init() { if (this.db) { this.hookThreadUpdate(); return; } this.db = new DataBoard('sounds'); this.migrateBeepSource(); this.hookThreadUpdate(); }, hookThreadUpdate() { if (this.threadUpdateHooked) return; this.threadUpdateHooked = true; $.on(d, 'ThreadUpdate', SoundManager.onThreadUpdate); }, onThreadUpdate(e) { if (!e.detail?.[404]) return; const thread = g.threads.get(e.detail.threadID); if (!thread) return; SoundManager.clearThreadPostOverrides(thread.board.ID, thread.ID); }, clearThreadPostOverrides(boardID, threadID, siteID = g.SITE.ID) { if (!this.db) return; this.db.delete({ siteID, boardID, threadID }); }, migrateBeepSource() { const src = Conf.beepSource; if (!src) return; const lib = Array.isArray(Conf.soundLibrary) ? Conf.soundLibrary : []; const alreadyIn = lib.some((e) => e.data === src); if (!alreadyIn) { const id = this.addToLibrary('Imported default', src); if (!Conf.defaultSoundId) this.setDefaultSoundId(id); } Conf.beepSource = ''; $.set('beepSource', ''); }, builtins() { return [ { id: BUILTIN_DEFAULT_ID, name: 'Beep', data: `data:audio/wav;base64,${Beep}`, builtin: true }, ]; }, library() { const user = Array.isArray(Conf.soundLibrary) ? Conf.soundLibrary : []; return [...this.builtins(), ...user]; }, getEntry(id) { if (!id) return undefined; return this.library().find(e => e.id === id); }, addToLibrary(name, data, cb) { const id = `user:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const next = [...(Array.isArray(Conf.soundLibrary) ? Conf.soundLibrary : []), { id, name, data }]; Conf.soundLibrary = next; $.set('soundLibrary', next, cb); return id; }, renameLibraryEntry(id, name, cb) { const list = Array.isArray(Conf.soundLibrary) ? Conf.soundLibrary : []; const next = list.map((e) => e.id === id ? { ...e, name } : e); Conf.soundLibrary = next; $.set('soundLibrary', next, cb); }, removeFromLibrary(id, cb) { const list = Array.isArray(Conf.soundLibrary) ? Conf.soundLibrary : []; const next = list.filter((e) => e.id !== id); Conf.soundLibrary = next; if (Conf.defaultSoundId === id) { Conf.defaultSoundId = ''; $.set('defaultSoundId', ''); } $.set('soundLibrary', next, cb); }, getDefaultSoundId() { return Conf.defaultSoundId || ''; }, setDefaultSoundId(id, cb) { Conf.defaultSoundId = id || ''; $.set('defaultSoundId', Conf.defaultSoundId, cb); }, isDefault(id) { const cur = Conf.defaultSoundId || ''; if (cur) return cur === id; return id === BUILTIN_DEFAULT_ID; }, boardKey(boardID, siteID = g.SITE.ID) { return `${siteID}/${boardID}`; }, getBoardOverride(boardID, siteID = g.SITE.ID) { const map = Conf.boardSounds || {}; return map[this.boardKey(boardID, siteID)]; }, setBoardOverride(boardID, soundId, cb) { const map = { ...(Conf.boardSounds || {}) }; const key = this.boardKey(boardID); if (soundId) { map[key] = soundId; } else { delete map[key]; } Conf.boardSounds = map; $.set('boardSounds', map, cb); }, allBoardOverrides() { const map = Conf.boardSounds || {}; const out = []; for (const key in map) { const [siteID, boardID] = key.split('/'); if (boardID) out.push({ siteID, boardID, soundId: map[key] }); } return out; }, getPostOverride({ siteID, boardID, threadID, postID }) { if (postID == null) return undefined; const entry = this.db?.get({ siteID, boardID, threadID }); return entry?.posts?.[postID]; }, setPostOverride({ siteID, boardID, threadID, postID }, soundId, cb) { if (!this.db || postID == null) return; const existing = this.db.get({ siteID, boardID, threadID }) || dict(); const posts = { ...(existing.posts || {}) }; if (soundId) { posts[postID] = soundId; } else { delete posts[postID]; } const next = { ...existing }; if (Object.keys(posts).length) { next.posts = posts; } else { delete next.posts; } if (next.posts && Object.keys(next.posts).length) { this.db.set({ siteID, boardID, threadID, val: next }, cb); } else { this.db.delete({ siteID, boardID, threadID }, cb); } }, allPostOverrides() { const out = []; const data = this.db?.data; if (!data) return out; for (const siteID in data) { const boards = data[siteID]?.boards; if (!boards) continue; for (const boardID in boards) { const threads = boards[boardID]; if (!threads || typeof threads !== 'object') continue; for (const threadID in threads) { const wrapper = threads[threadID]; if (!wrapper?.posts) continue; for (const postID in wrapper.posts) { const soundId = wrapper.posts[postID]; if (soundId) out.push({ siteID, boardID, threadID, postID, soundId }); } } } } return out; }, resolveSource({ quotedYouPost, context, }) { const tryId = (id) => { if (!id) return null; return this.getEntry(id)?.data || null; }; if (quotedYouPost) { const src = tryId(this.getPostOverride(quotedYouPost)); if (src) return src; } const boardSrc = tryId(this.getBoardOverride(context.boardID)); if (boardSrc) return boardSrc; const defaultSrc = tryId(this.getDefaultSoundId()); if (defaultSrc) return defaultSrc; return this.getEntry(BUILTIN_DEFAULT_ID).data; }, }; var ThreadUpdater = { init() { let sc; SoundManager.init(); ThreadUpdater.initBeepChannel(); this.audio = $.el('audio'); this.audio.preload = 'auto'; this.audio.src = this.beep; $.on(this.audio, 'error', () => { new Notice('error', this.audio.error.message || 'Error when trying to play thread updater beep.', 15); }); if ((g.VIEW !== 'thread') || !Conf['Thread Updater']) return; this.enabled = true; if (Conf['Updater and Stats in Header']) { this.dialog = (sc = $.el('span', { id: 'updater' })); $.extend(sc, { innerHTML: '' }); Header.addShortcut('updater', sc, 100); } else { this.dialog = (sc = UI.dialog('updater', { innerHTML: '
' })); $.addClass(doc, 'float'); $.ready(() => $.add(d.body, sc)); } this.checkPostCount = 0; this.timer = $('#update-timer', sc); this.status = $('#update-status', sc); $.on(this.timer, 'click', this.update); $.on(this.status, 'click', this.update); const updateLink = $.el('span', { className: 'brackets-wrap updatelink' }); $.extend(updateLink, { innerHTML: 'Update' }); Main.ready(function () { let navLinksBot; if (navLinksBot = $('.navLinksBot')) { return $.add(navLinksBot, [$.tn(' '), updateLink]); } }); $.on(updateLink.firstElementChild, 'click', this.update); const subEntries = []; for (const name in Config.updater.checkbox) { var conf = Config.updater.checkbox[name]; const el = UI.checkbox(name, name); el.title = conf[1]; var input = el.firstElementChild; $.on(input, 'change', $.cb.checked); if (input.name === 'Scroll BG') { $.on(input, 'change', this.cb.scrollBG); this.cb.scrollBG(); } else if (input.name === 'Auto Update') { $.on(input, 'change', this.setInterval); } subEntries.push({ el }); } this.settings = $.el('span', { innerHTML: 'Interval' }); $.on(this.settings, 'click', this.intervalShortcut); subEntries.push({ el: this.settings }); Header.menu.addEntry(this.entry = { el: $.el('span', { textContent: 'Updater' }), order: 110, subEntries }); return Callbacks.Thread.push({ name: 'Thread Updater', cb: this.node }); }, node() { ThreadUpdater.thread = this; ThreadUpdater.root = this.nodes.root; ThreadUpdater.outdateCount = 0; ThreadUpdater.postIDs = []; ThreadUpdater.fileIDs = []; this.posts.forEach(function (post) { ThreadUpdater.postIDs.push(post.ID); if (post.file) { return ThreadUpdater.fileIDs.push(post.ID); } }); ThreadUpdater.cb.interval.call($.el('input', { value: Conf['Interval'] })); $.on(d, 'QRPostSuccessful', ThreadUpdater.cb.checkpost); $.on(d, 'visibilitychange', ThreadUpdater.cb.visibility); return ThreadUpdater.setInterval(); }, beep: `data:audio/wav;base64,${Beep}`, playBeep(repeatIfPlaying = true) { const lib = SoundManager.getEntry(SoundManager.getDefaultSoundId()); ThreadUpdater.requestPlaySound(lib?.data || ThreadUpdater.beep, repeatIfPlaying); }, initBeepChannel() { if (ThreadUpdater.beepChannel || !window.BroadcastChannel) return; ThreadUpdater.beepChannel = new BroadcastChannel(`${g.NAMESPACE} updater-beep`); $.on(ThreadUpdater.beepChannel, 'message', ThreadUpdater.onBeepMessage); $.on(d, 'PlayUpdaterSound', ThreadUpdater.onPlayUpdaterSound); }, onBeepMessage(e) { const source = e.data?.source; if (!source || d.hidden || !d.hasFocus()) return; ThreadUpdater.playSound(source, false); }, onPlayUpdaterSound(e) { const { post, predicate } = e.detail || {}; if (!post?.board || !post?.thread) return; const context = { boardID: post.board.ID, threadID: post.thread.ID }; const isQuotingYou = predicate === ' replied to you'; if (isQuotingYou && Conf['Beep Quoting You']) { let quotedYou; for (const ql of post.nodes?.quotelinks || []) { const data = Get.postDataFromLink(ql); if (QuoteYou.db?.get(data)) { quotedYou = { boardID: data.boardID, threadID: data.threadID, postID: data.postID }; break; } } ThreadUpdater.requestPlaySound(SoundManager.resolveSource({ quotedYouPost: quotedYou, context })); } else if (!isQuotingYou && Conf['Beep']) { ThreadUpdater.requestPlaySound(SoundManager.resolveSource({ context })); } }, requestPlaySound(source, repeatIfPlaying = true) { if (!source) return; const now = Date.now(); if (ThreadUpdater.lastBeepSource === source && (now - (ThreadUpdater.lastBeepAt || 0)) < 300) return; ThreadUpdater.lastBeepSource = source; ThreadUpdater.lastBeepAt = now; const away = d.hidden || !d.hasFocus(); if (!away) { ThreadUpdater.playSound(source, repeatIfPlaying); return; } ThreadUpdater.broadcastBeep(source); ThreadUpdater.playSound(source, repeatIfPlaying); }, broadcastBeep(source) { ThreadUpdater.beepChannel?.postMessage({ source }); }, playSound(source, repeatIfPlaying = true) { const { audio } = ThreadUpdater; if (!source) source = ThreadUpdater.beep; if (audio.src !== source) { audio.src = source; audio.load(); } const configuredVolume = Number(Conf.beepVolume); audio.volume = Number.isFinite(configuredVolume) ? Math.max(.01, Math.min(configuredVolume, 1)) : 1; audio.currentTime = 0; if (audio.paused) { const playPromise = audio.play(); if (playPromise && typeof playPromise.catch === 'function') { playPromise.catch((err) => { if (err?.name === 'NotAllowedError') { ThreadUpdater.broadcastBeep(source); ThreadUpdater.armAudioUnlock(source); } }); } } else if (repeatIfPlaying) { $.one(audio, 'ended', () => ThreadUpdater.playSound(source, false)); } }, armAudioUnlock(source) { ThreadUpdater.pendingAudioSource = source; if (ThreadUpdater.audioUnlockArmed) { return; } ThreadUpdater.audioUnlockArmed = true; const unlock = () => { $.off(d, 'pointerdown keydown', unlock); ThreadUpdater.audioUnlockArmed = false; const pendingSource = ThreadUpdater.pendingAudioSource || source; delete ThreadUpdater.pendingAudioSource; ThreadUpdater.playSound(pendingSource, false); }; $.on(d, 'pointerdown keydown', unlock); const now = Date.now(); if (!ThreadUpdater.lastAudioBlockNoticeAt || (now - ThreadUpdater.lastAudioBlockNoticeAt > 10000)) { ThreadUpdater.lastAudioBlockNoticeAt = now; new Notice('warning', 'Sound was blocked by browser autoplay in this tab. Click or press a key in this tab to enable it.', 8); } }, findFirstQuotedYouPost(posts) { if (!QuoteYou.db) return null; for (const post of posts) { if (!post.nodes?.quotelinks) continue; for (const ql of post.nodes.quotelinks) { const data = Get.postDataFromLink(ql); if (QuoteYou.db.get(data)) { return { boardID: data.boardID, threadID: data.threadID, postID: data.postID }; } } } return null; }, postsQuoteYou(posts) { if (!QuoteYou.db) return false; for (const post of posts) { if (!post.nodes?.quotelinks) continue; for (const ql of post.nodes.quotelinks) { if (QuoteYou.db.get(Get.postDataFromLink(ql))) return true; } } return false; }, cb: { checkpost(e) { if (e.detail.threadID !== ThreadUpdater.thread.ID) { return; } ThreadUpdater.postID = e.detail.postID; ThreadUpdater.checkPostCount = 0; ThreadUpdater.outdateCount = 0; return ThreadUpdater.setInterval(); }, visibility() { if (d.hidden) { return; } ThreadUpdater.outdateCount = 0; if (ThreadUpdater.pendingAudioSource) { const pendingSource = ThreadUpdater.pendingAudioSource; delete ThreadUpdater.pendingAudioSource; ThreadUpdater.audioUnlockArmed = false; ThreadUpdater.playSound(pendingSource, false); } if (ThreadUpdater.seconds > ThreadUpdater.interval) { return ThreadUpdater.setInterval(); } }, scrollBG() { return ThreadUpdater.scrollBG = Conf['Scroll BG'] ? () => true : () => !d.hidden; }, interval(e) { let val = parseInt(this.value, 10); if (val < 1) { val = 1; } ThreadUpdater.interval = (this.value = val); if (e) { return $.cb.value.call(this); } }, load() { if (this !== ThreadUpdater.req) { return; } switch (this.status) { case 200: ThreadUpdater.parse(this); if (ThreadUpdater.thread.isArchived) { return ThreadUpdater.kill(); } else { return ThreadUpdater.setInterval(); } case 404: return $.ajax(g.SITE.urls.catalogJSON({ boardID: ThreadUpdater.thread.board.ID }), { onloadend() { let confirmed; if (this.status === 200) { confirmed = true; for (var page of this.response) { for (var thread of page.threads) { if (thread.no === ThreadUpdater.thread.ID) { confirmed = false; break; } } } } else { confirmed = false; } if (confirmed) { ThreadUpdater.kill(); } else { ThreadUpdater.error(this); } } }); default: return ThreadUpdater.error(this); } } }, kill() { ThreadUpdater.thread.kill(); ThreadUpdater.setInterval(); return $.event('ThreadUpdate', { 404: true, threadID: ThreadUpdater.thread.fullID }); }, error(req) { if (req.status === 304) { ThreadUpdater.set('status', ''); } ThreadUpdater.setInterval(); if (!req.status) { return ThreadUpdater.set('status', 'Connection Error', 'warning'); } else if (req.status !== 304) { return ThreadUpdater.set('status', `${req.statusText} (${req.status})`, 'warning'); } }, setInterval() { clearTimeout(ThreadUpdater.timeoutID); if (ThreadUpdater.thread.isDead) { ThreadUpdater.set('status', (ThreadUpdater.thread.isArchived ? 'Archived' : '404'), 'warning'); ThreadUpdater.set('timer', ''); return; } if (ThreadUpdater.postID && (ThreadUpdater.checkPostCount < 5)) { ThreadUpdater.set('timer', '...', 'loading'); ThreadUpdater.timeoutID = setTimeout(ThreadUpdater.update, ++ThreadUpdater.checkPostCount * SECOND); return; } if (!Conf['Auto Update']) { ThreadUpdater.set('timer', 'Update'); return; } const { interval } = ThreadUpdater; if (Conf['Optional Increase']) { const limit = d.hidden ? 10 : 5; const j = Math.min(ThreadUpdater.outdateCount, limit); const cur = (Math.floor(interval * 0.1) || 1) * j * j; ThreadUpdater.seconds = $.minmax(cur, interval, 300); } else { ThreadUpdater.seconds = interval; } return ThreadUpdater.timeout(); }, intervalShortcut() { const focusInterval = (section) => { if (!section?.classList?.contains('section-threads-posts')) return; const fs = $.id('xt-updater-settings'); if (fs instanceof HTMLDetailsElement) fs.open = true; const input = $('input[name=Interval]', section); input?.focus(); }; if (Settings.dialog) { const threadsSection = Settings.sections.find((s) => s.title === 'Threads & Posts'); if (threadsSection) Settings.openSection.call(threadsSection); focusInterval($('section', Settings.dialog)); return; } Settings.open('Threads & Posts'); const onOpen = (e) => { $.off(d, 'OpenSettings', onOpen); focusInterval(e.detail); }; $.on(d, 'OpenSettings', onOpen); }, set(name, text, klass) { let node; const el = ThreadUpdater[name]; if ((node = el.firstChild)) { node.data = text; } else { el.textContent = text; } return el.className = klass ?? (text === '' ? 'empty' : ''); }, timeout() { if (ThreadUpdater.seconds) { ThreadUpdater.set('timer', ThreadUpdater.seconds); ThreadUpdater.timeoutID = setTimeout(ThreadUpdater.timeout, 1000); } else { ThreadUpdater.outdateCount++; ThreadUpdater.update(); } return ThreadUpdater.seconds--; }, update() { let oldReq; clearTimeout(ThreadUpdater.timeoutID); ThreadUpdater.set('timer', '...', 'loading'); if (oldReq = ThreadUpdater.req) { delete ThreadUpdater.req; oldReq.abort(); } return ThreadUpdater.req = $.whenModified(g.SITE.urls.threadJSON({ boardID: ThreadUpdater.thread.board.ID, threadID: ThreadUpdater.thread.ID }), 'ThreadUpdater', ThreadUpdater.cb.load, { timeout: MINUTE }); }, updateThreadStatus(type, status) { if (!(ThreadUpdater.thread[`is${type}`] !== status)) { return; } ThreadUpdater.thread.setStatus(type, status); if ((type === 'Closed') && ThreadUpdater.thread.isArchived) { return; } const change = type === 'Sticky' ? status ? 'now a sticky' : 'not a sticky anymore' : status ? 'now closed' : 'not closed anymore'; return new Notice('info', `The thread is ${change}.`, 30); }, parse(req) { let ID, ipCountEl, post; const postObjects = req.response.posts; const OP = postObjects[0]; const thread = ThreadUpdater.thread; const { board } = thread; const lastPost = ThreadUpdater.postIDs[ThreadUpdater.postIDs.length - 1]; if ((postObjects[postObjects.length - 1].no < lastPost) && (((+new Date(req.getResponseHeader('Last-Modified') || '')) - thread.posts.get(lastPost).info.date) < (30 * SECOND))) { return; } g.SITE.Build.spoilerRange[board] = OP.custom_spoiler; thread.setStatus('Archived', !!OP.archived); ThreadUpdater.updateThreadStatus('Sticky', !!OP.sticky); ThreadUpdater.updateThreadStatus('Closed', !!OP.closed); thread.postLimit = !!OP.bumplimit; thread.fileLimit = !!OP.imagelimit; if (OP.unique_ips) thread.ipCount = OP.unique_ips; const posts = []; const index = []; const files = []; const newPosts = []; for (var postObject of postObjects) { ID = postObject.no; index.push(ID); if (postObject.fsize) { files.push(ID); } if (ID <= lastPost) { continue; } if ((post = thread.posts.get(ID)) && !post.isFetchedQuote) { post.resurrect(); continue; } newPosts.push(`${board}.${ID}`); var node = g.SITE.Build.postFromObject(postObject, board.ID); posts.push(new Post(node, thread, board)); if (ThreadUpdater.postID === ID) { delete ThreadUpdater.postID; } } const deletedPosts = []; for (ID of ThreadUpdater.postIDs) { if (!index.includes(ID)) { thread.posts.get(ID).kill(); deletedPosts.push(`${board}.${ID}`); } } ThreadUpdater.postIDs = index; const deletedFiles = []; for (ID of ThreadUpdater.fileIDs) { if (!(files.includes(ID) || deletedPosts.includes(`${board}.${ID}`))) { thread.posts.get(ID).kill(true); deletedFiles.push(`${board}.${ID}`); } } ThreadUpdater.fileIDs = files; if (!posts.length) { ThreadUpdater.set('status', ''); } else { ThreadUpdater.set('status', `+${posts.length}`, 'new'); ThreadUpdater.outdateCount = 0; const unreadCount = Unread.posts?.size ?? 0; Unread.postsQuotingYou?.size ?? 0; Main.callbackNodes('Post', posts); if (d.hidden || !d.hasFocus()) { const newUnread = (Unread.posts?.size ?? 0) > unreadCount; const quotingYouInBatch = ThreadUpdater.postsQuoteYou(posts); const context = { boardID: thread.board.ID, threadID: thread.ID }; if (Conf['Beep Quoting You'] && quotingYouInBatch && !Conf['Desktop Notifications']) { const quotedYou = ThreadUpdater.findFirstQuotedYouPost(posts); ThreadUpdater.requestPlaySound(SoundManager.resolveSource({ quotedYouPost: quotedYou ?? undefined, context })); } else if (Conf['Beep'] && posts.length > 0 && (newUnread || unreadCount === 0)) { ThreadUpdater.requestPlaySound(SoundManager.resolveSource({ context })); } } const scroll = Conf['Auto Scroll'] && ThreadUpdater.scrollBG() && ((ThreadUpdater.root.getBoundingClientRect().bottom - doc.clientHeight) < 25); let firstPost = null; for (post of posts) { if (!QuoteThreading.insert(post)) { if (!firstPost) { firstPost = post.nodes.root; } $.add(ThreadUpdater.root, post.nodes.root); } } $.event('PostsInserted', null, ThreadUpdater.root); if (scroll) { if (Conf['Bottom Scroll']) { window.scrollTo(0, d.body.clientHeight); } else { if (firstPost) { Header.scrollTo(firstPost); } } } } if (OP.unique_ips && (ipCountEl = $.id('unique-ips'))) { ipCountEl.textContent = OP.unique_ips; ipCountEl.previousSibling.textContent = ipCountEl.previousSibling.textContent.replace(/\b(?:is|are)\b/, OP.unique_ips === 1 ? 'is' : 'are'); ipCountEl.nextSibling.textContent = ipCountEl.nextSibling.textContent.replace(/\bposters?\b/, OP.unique_ips === 1 ? 'poster' : 'posters'); } return $.event('ThreadUpdate', { 404: false, threadID: thread.fullID, newPosts, deletedPosts, deletedFiles, postCount: OP.replies + 1, fileCount: OP.images + !!OP.fsize, ipCount: OP.unique_ips }); } }; var Keybinds = { init() { if (!Conf['Keybinds']) { return; } for (var hotkey in Config.hotkeys) { $.sync(hotkey, Keybinds.sync); } var init = function () { $.off(d, '4chanXInitFinished', init); $.on(d, 'keydown', Keybinds.keydown); for (var node of $$('[accesskey]')) { node.removeAttribute('accesskey'); } }; return $.on(d, '4chanXInitFinished', init); }, sync(key, hotkey) { return Conf[hotkey] = key; }, keydown(e) { let key, thread, threadRoot; let catalog, notifications; if (!(key = Keybinds.keyCode(e))) { return; } const target = e.target; if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) { if (!/(Esc|Alt|Ctrl|Meta|Shift\+\w{2,})/.test(key) || !!/^Alt\+(\d|Up|Down|Left|Right)$/.test(key)) { return; } } if (g.VIEW === 'index' || g.VIEW === 'thread') { threadRoot = Nav.getThread(); thread = Get.threadFromRoot(threadRoot); } let hasAction = false; if (key === Conf['Toggle board list'] && Conf['Custom Board Navigation']) { Header.toggleBoardList(); hasAction = true; } if (key === Conf['Toggle header']) { Header.toggleBarVisibility(); hasAction = true; } if (key === Conf['Open empty QR'] && QR.postingIsEnabled) { Keybinds.qr(); hasAction = true; } if (key === Conf['Open QR'] && QR.postingIsEnabled && threadRoot) { Keybinds.qr(threadRoot); hasAction = true; } if (key === Conf['Open settings']) { Settings.open(); hasAction = true; } if (key === Conf['Close']) { if (Settings.dialog) { Settings.close(); } else if ((notifications = $$('.notification')).length) { for (var notification of notifications) { $('.close', notification).click(); } } else if (QR.nodes?.preview) { QR.closePreview(); } else if (QR.nodes && !(QR.nodes.el.hidden || (window.getComputedStyle(QR.nodes.form).display === 'none'))) { if (Conf['Persistent QR']) { QR.hide(); } else { QR.close(); } } else if (Embedding.lastEmbed) { Embedding.closeFloat(); } hasAction = true; } if (key === Conf['Spoiler tags'] && target.nodeName === 'TEXTAREA') { Keybinds.tags('spoiler', target); hasAction = true; } if (key === Conf['Code tags'] && target.nodeName === 'TEXTAREA') { Keybinds.tags('code', target); hasAction = true; } if (key === Conf['Eqn tags'] && target.nodeName === 'TEXTAREA') { Keybinds.tags('eqn', target); hasAction = true; } if (key === Conf['Math tags'] && target.nodeName === 'TEXTAREA') { Keybinds.tags('math', target); hasAction = true; } if (key === Conf['SJIS tags'] && target.nodeName === 'TEXTAREA') { Keybinds.tags('sjis', target); hasAction = true; } if (key === Conf['Toggle sage'] && QR.nodes && !QR.nodes.el.hidden) { Keybinds.sage(); hasAction = true; } if (key === Conf['Toggle comment preview'] && QR.nodes && !QR.nodes.el.hidden) { QR.toggleCommentPreview(e); hasAction = true; } if (key === Conf['Toggle drafts'] && Conf['QR Drafts'] && QR.nodes && !QR.nodes.el.hidden) { QR.drafts.togglePanel(); hasAction = true; } if (key === Conf['Toggle Cooldown'] && QR.nodes && !QR.nodes.el.hidden && $.hasClass(QR.nodes.fileSubmit, 'custom-cooldown')) { QR.toggleCustomCooldown(); hasAction = true; } if (key === Conf['Post from URL'] && QR.postingIsEnabled) { QR.handleUrl(''); hasAction = true; } if (key === Conf['Add new post'] && QR.postingIsEnabled) { QR.addPost(); hasAction = true; } if (key === Conf['Submit QR'] && QR.nodes && !QR.nodes.el.hidden && !QR.status()) { QR.submit(); hasAction = true; } if (key === Conf['Update']) { switch (g.VIEW) { case 'thread': if (ThreadUpdater.enabled) ThreadUpdater.update(); hasAction = true; break; case 'index': if (Index.enabled) Index.update(); hasAction = true; } } if (key === Conf['Watch'] && ThreadWatcher.enabled && thread) { ThreadWatcher.toggle(thread); hasAction = true; } if (key === Conf['Undo last hide/filter'] && target.nodeName !== 'INPUT' && target.nodeName !== 'TEXTAREA' && !target.isContentEditable) { UndoStack.undo(); hasAction = true; } if (key === Conf['Update thread watcher'] && ThreadWatcher.enabled) { ThreadWatcher.buttonFetchAll(); hasAction = true; } if (key === Conf['Toggle thread watcher'] && ThreadWatcher.enabled) { ThreadWatcher.toggleWatcher(); hasAction = true; } if (key === Conf['Toggle threading'] && QuoteThreading.ready) { QuoteThreading.toggleThreading(); hasAction = true; } if (key === Conf['Mark thread read'] && g.VIEW === 'index' && thread && UnreadIndex.enabled) { UnreadIndex.markRead.call(threadRoot); hasAction = true; } if (key === Conf['Expand image'] && ImageExpand.enabled && threadRoot) { var post = Get.postFromNode(Keybinds.post(threadRoot)); if (post?.file) { ImageExpand.toggle(post); hasAction = true; } } if (key === Conf['Expand images'] && ImageExpand.enabled) { ImageExpand.cb.toggleAll(); hasAction = true; } if (key === Conf['Open Gallery'] && Gallery.enabled) { Gallery.cb.toggle(); hasAction = true; } if (key === Conf['Download all media'] && Conf['Download All Media'] && (g.VIEW === 'thread' || g.VIEW === 'index' || g.VIEW === 'catalog')) { DownloadAll.cb.open(); hasAction = true; } if (key === Conf['fappeTyme'] && FappeTyme.nodes?.fappe) { FappeTyme.toggle('fappe'); hasAction = true; } if (key === Conf['werkTyme'] && FappeTyme.nodes?.werk) { FappeTyme.toggle('werk'); hasAction = true; } if (key === Conf['Front page']) { if (Index.enabled) { Index.userPageNav(1); } else { location.href = `/${g.BOARD}/`; } hasAction = true; } if (key === Conf['Open front page']) { $.open(`${location.origin}/${g.BOARD}/`); hasAction = true; } if (key === Conf['Next page'] && g.VIEW === 'index' && !g.SITE.isOnePage?.(g.BOARD)) { if (Index.enabled) { if (!['paged', 'infinite'].includes(Conf['Index Mode'])) { return; } $('.next button', Index.pagelist).click(); } else { $(g.SITE.selectors.nav.next)?.click(); } hasAction = true; } if (key === Conf['Previous page'] && g.VIEW === 'index' && !g.SITE.isOnePage?.(g.BOARD)) { if (Index.enabled) { if (!['paged', 'infinite'].includes(Conf['Index Mode'])) { return; } $('.prev button', Index.pagelist).click(); } else { $(g.SITE.selectors.nav.prev)?.click(); } hasAction = true; } if (key === Conf['Search form'] && g.VIEW === 'index') { var searchInput = Index.enabled ? Index.searchInput : g.SITE.selectors.searchBox ? $(g.SITE.selectors.searchBox) : undefined; if (searchInput) { Header.scrollToIfNeeded(searchInput); searchInput.focus(); hasAction = true; } } if (key === Conf['Paged mode'] && Index.enabledOn(g.BOARD)) { location.href = g.VIEW === 'index' ? '#paged' : `/${g.BOARD}/#paged`; } if (key === Conf['Infinite scrolling mode'] && Index.enabledOn(g.BOARD)) { location.href = g.VIEW === 'index' ? '#infinite' : `/${g.BOARD}/#infinite`; } if (key === Conf['All pages mode'] && Index.enabledOn(g.BOARD)) { location.href = g.VIEW === 'index' ? '#all-pages' : `/${g.BOARD}/#all-pages`; } if (key === Conf['Open catalog'] && (catalog = CatalogLinks.catalog())) { location.href = catalog; } if (key === Conf['Cycle sort type'] && Index.enabled) { Index.cycleSortType(); hasAction = true; } if (key === Conf['Next thread'] && g.VIEW === 'index' && threadRoot) { Nav.scroll(+1); hasAction = true; } if (key === Conf['Previous thread'] && g.VIEW === 'index' && threadRoot) { Nav.scroll(-1); hasAction = true; } if (key === Conf['Expand thread'] && g.VIEW === 'index' && threadRoot) { ExpandThread.toggle(thread); Header.scrollTo(threadRoot); hasAction = true; } if (key === Conf['Open thread'] && g.VIEW === 'index' && threadRoot) { Keybinds.open(thread); hasAction = true; } if (key === Conf['Open thread tab'] && g.VIEW === 'index' && threadRoot) { Keybinds.open(thread, true); hasAction = true; } if (key === Conf['Next reply'] && threadRoot) { Keybinds.hl(+1, threadRoot); hasAction = true; } if (key === Conf['Previous reply'] && threadRoot) { Keybinds.hl(-1, threadRoot); hasAction = true; } if (key === Conf['Deselect reply'] && threadRoot) { Keybinds.hl(0, threadRoot); hasAction = true; } if (key === Conf['Hide'] && thread && ThreadHiding.db) { Header.scrollTo(threadRoot); ThreadHiding.toggle(thread); hasAction = true; } if (key === Conf['Quick Filter MD5'] && threadRoot) { post = Keybinds.post(threadRoot); Keybinds.hl(+1, threadRoot); Filter.quickFilterMD5.call(post, e); hasAction = true; } if (key === Conf['Previous Post Quoting You'] && threadRoot && QuoteYou.db) { QuoteYou.cb.seek('preceding'); hasAction = true; } if (key === Conf['Next Post Quoting You'] && threadRoot && QuoteYou.db) { QuoteYou.cb.seek('following'); hasAction = true; } if (hasAction) { e.preventDefault(); e.stopPropagation(); } }, keyCode(e) { let key = (() => { let kc; switch ((kc = e.keyCode)) { case 8: return ''; case 13: return 'Enter'; case 27: return 'Esc'; case 32: return 'Space'; case 37: return 'Left'; case 38: return 'Up'; case 39: return 'Right'; case 40: return 'Down'; case 188: return 'Comma'; case 190: return 'Period'; case 191: return 'Slash'; case 59: case 186: return 'Semicolon'; default: if ((48 <= kc && kc <= 57) || (65 <= kc && kc <= 90)) { return String.fromCharCode(kc).toLowerCase(); } else if (96 <= kc && kc <= 105) { return String.fromCharCode(kc - 48); } else { return null; } } })(); if (key) { if (e.altKey) { key = 'Alt+' + key; } if (e.ctrlKey) { key = 'Ctrl+' + key; } if (e.metaKey) { key = 'Meta+' + key; } if (e.shiftKey) { key = 'Shift+' + key; } } return key; }, modifierString(e) { const parts = []; if (e.altKey) { parts.push('Alt'); } if (e.ctrlKey) { parts.push('Ctrl'); } if (e.metaKey) { parts.push('Meta'); } if (e.shiftKey) { parts.push('Shift'); } return parts.join('+'); }, post(thread) { const s = g.SITE.selectors; return ($(`${s.postContainer}${s.highlightable.reply}.${g.SITE.classes.highlight}`, thread) || $(`${g.SITE.isOPContainerThread ? s.thread : s.postContainer}${s.highlightable.op}`, thread)); }, qr(thread) { QR.open(); if (thread != null) { QR.quote.call(Keybinds.post(thread), undefined); } return QR.nodes.com.focus(); }, tags(tag, ta) { BoardConfig.ready(function () { const { config } = g.BOARD; const supported = (() => { switch (tag) { case 'spoiler': return !!config.spoilers; case 'code': return !!config.code_tags; case 'math': case 'eqn': return !!config.math_tags; case 'sjis': return !!config.sjis_tags; } })(); if (!supported) { return new Notice('warning', `[${tag}] tags are not supported on /${g.BOARD}/.`, 20); } }); const { value } = ta; const selStart = ta.selectionStart; const selEnd = ta.selectionEnd; ta.value = value.slice(0, selStart) + `[${tag}]` + value.slice(selStart, selEnd) + `[/${tag}]` + value.slice(selEnd); const range = (`[${tag}]`).length + selEnd; ta.setSelectionRange(range, range); return $.event('input', null, ta); }, sage() { const isSage = /sage/i.test(QR.nodes.email.value); return QR.nodes.email.value = isSage ? "" : "sage"; }, open(thread, tab) { if (g.VIEW !== 'index') { return; } const url = Get.url('thread', thread); if (tab) { return $.open(url); } else { return location.href = url; } }, hl(delta, thread) { const replySelector = `${g.SITE.selectors.postContainer}${g.SITE.selectors.highlightable.reply}`; const { highlight } = g.SITE.classes; const postEl = $(`${replySelector}.${highlight}`, thread); if (!delta) { if (postEl) { $.rmClass(postEl, highlight); } return; } if (postEl) { const { height } = postEl.getBoundingClientRect(); if ((Header.getTopOf(postEl) >= -height) && (Header.getBottomOf(postEl) >= -height)) { let next; const post = Get.postFromNode(postEl); if (!post) { return; } const { root } = post.nodes; const axis = delta === +1 ? 'following' : 'preceding'; if (!(next = $.x(`${axis}-sibling::${g.SITE.xpath.replyContainer}[not(@hidden) and not(child::div[@class='stub'])][1]`, root))) { return; } if (!next.matches(replySelector)) { next = $(replySelector, next); } Header.scrollToIfNeeded(next, delta === +1); $.addClass(next, highlight); $.rmClass(postEl, highlight); return; } $.rmClass(postEl, highlight); } const replies = $$(replySelector, thread); if (delta === -1) { replies.reverse(); } for (var reply of replies) { if (((delta === +1) && (Header.getTopOf(reply) > 0)) || ((delta === -1) && (Header.getBottomOf(reply) > 0))) { $.addClass(reply, highlight); return; } } } }; const Captcha = { cache: { init() { $.on(d, 'SaveCaptcha', e => { return this.saveAPI(e.detail); }); return $.on(d, 'NoCaptcha', e => { return this.noCaptcha(e.detail); }); }, captchas: [], submitCB: null, prerequested: null, timer: null, getCount() { return this.captchas.length; }, neededRaw() { return !(this.haveCookie() || this.captchas.length || QR.req || this.submitCB) && ((QR.posts.length > 1) || !QR.posts[0].isOnlyQuotes() || QR.posts[0].file); }, needed() { return this.neededRaw() && $.event('LoadCaptcha'); }, haveCookie() { return /\b_ct=/.test(d.cookie) && (QR.posts[0].thread !== 'new'); }, getOne(isReply) { let captcha; delete this.prerequested; this.clear(); if (captcha = this.captchas.shift()) { this.count(); return captcha; } else { return null; } }, request(isReply) { if (!this.submitCB) { if ($.event('RequestCaptcha', { isReply })) { return; } } return (cb) => { this.submitCB = cb; return this.updateCount(); }; }, abort() { if (this.submitCB) { delete this.submitCB; $.event('AbortCaptcha'); return this.updateCount(); } }, saveAPI(captcha) { let cb; if (cb = this.submitCB) { delete this.submitCB; cb(captcha); return this.updateCount(); } else { return this.save(captcha); } }, noCaptcha(detail) { let cb; if (cb = this.submitCB) { if (!this.haveCookie() || detail?.error) { QR.error(detail?.error || 'Failed to retrieve captcha.'); QR.captcha.setup(d.activeElement === QR.nodes.status); } delete this.submitCB; cb(); return this.updateCount(); } }, save(captcha) { let cb; if (cb = this.submitCB) { this.abort(); cb(captcha); return; } this.captchas.push(captcha); this.captchas.sort((a, b) => a.timeout - b.timeout); return this.count(); }, clear() { if (this.captchas.length) { let i; const now = Date.now(); for (i = 0; i < this.captchas.length; i++) { var captcha = this.captchas[i]; if (captcha.timeout > now) { break; } } if (i) { this.captchas = this.captchas.slice(i); return this.count(); } } }, count() { clearTimeout(this.timer); if (this.captchas.length) { this.timer = setTimeout(this.clear.bind(this), this.captchas[0].timeout - Date.now()); } return this.updateCount(); }, updateCount() { return $.event('CaptchaCount', this.captchas.length); } }, replace: CaptchaReplace, t: CaptchaT, v2: { lifetime: 2 * MINUTE, isEnabled: false, noscript: false, nodes: null, timer: null, init() { if (d.cookie.indexOf('pass_enabled=1') >= 0) { return; } if (!(this.isEnabled = !!$('#g-recaptcha, #captcha-forced-noscript') || !$.id('postForm'))) { return; } if (this.noscript = Conf['Force Noscript Captcha'] || !Main.jsEnabled) { $.addClass(QR.nodes.el, 'noscript-captcha'); } Captcha.cache.init(); $.on(d, 'CaptchaCount', this.count.bind(this)); const root = $.el('div', { className: 'captcha-root' }); $.extend(root, { innerHTML: '
' }); const counter = $('.captcha-counter > a', root); this.nodes = { root, counter }; this.count(); $.addClass(QR.nodes.el, 'has-captcha', 'captcha-v2'); $.after(QR.nodes.com.parentNode, root); $.on(counter, 'click', this.toggle.bind(this)); $.on(counter, 'keydown', e => { if (Keybinds.keyCode(e) !== 'Space') { return; } if (e.shiftKey) { return; } this.toggle(); e.preventDefault(); return e.stopPropagation(); }); return $.on(window, 'captcha:success', () => { return $.queueTask(() => this.save(false)); }); }, timeouts: {}, prevNeeded: 0, noscriptURL() { let lang; let url = `https://www.google.com/recaptcha/api/fallback?k=${meta.recaptchaKey}`; if (lang = Conf['captchaLanguage'].trim()) { url += `&hl=${encodeURIComponent(lang)}`; } return url; }, moreNeeded() { return $.queueTask(() => { const needed = Captcha.cache.needed(); if (needed && !this.prevNeeded) { this.setup(QR.cooldown.auto && (d.activeElement === QR.nodes.status)); } return this.prevNeeded = needed; }); }, toggle() { if (this.nodes.container && !this.timeouts.destroy) { return this.destroy(); } else { return this.setup(true, true); } }, setup(focus, force) { if (!this.isEnabled || (!Captcha.cache.needed() && !force)) { return; } if (focus) { $.addClass(QR.nodes.el, 'focus'); this.nodes.counter.focus(); } if (this.timeouts.destroy) { clearTimeout(this.timeouts.destroy); delete this.timeouts.destroy; return this.reload(); } if (this.nodes.container) { $.queueTask(() => { let iframe; if (this.nodes.container && (d.activeElement === this.nodes.counter) && (iframe = $('iframe[src^="https://www.google.com/recaptcha/"]', this.nodes.container))) { iframe.focus(); return QR.focus(); } }); return; } this.nodes.container = $.el('div', { className: 'captcha-container' }); $.prepend(this.nodes.root, this.nodes.container); new MutationObserver(this.afterSetup.bind(this)).observe(this.nodes.container, { childList: true, subtree: true }); if (this.noscript) { return this.setupNoscript(); } else { return this.setupJS(); } }, setupNoscript() { const iframe = $.el('iframe', { id: 'qr-captcha-iframe', scrolling: 'no', src: this.noscriptURL() }); const div = $.el('div'); const textarea = $.el('textarea'); $.add(div, textarea); return $.add(this.nodes.container, [iframe, div]); }, setupJS() { const render = () => { const { classList } = document.documentElement; const container = $('#qr .captcha-container'); if (!container) { return; } container.dataset.widgetID = window.grecaptcha.render(container, { sitekey: meta.recaptchaKey, theme: classList.contains('tomorrow') || classList.contains('spooky') || classList.contains('dark-captcha') ? 'dark' : 'light', callback: (response) => window.dispatchEvent(new CustomEvent('captcha:success', { detail: response })) }); }; if (window.grecaptcha) { render(); } else { const cbNative = window.onRecaptchaLoaded; window.onRecaptchaLoaded = function () { render(); cbNative?.(); }; if (!document.head.querySelector('script[src^="https://www.google.com/recaptcha/api.js"]')) { const script = document.createElement('script'); script.src = 'https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoaded&render=explicit'; document.head.appendChild(script); } } }, afterSetup(mutations) { for (var mutation of mutations) { for (var node of mutation.addedNodes) { var iframe, textarea; if (iframe = $.x('./descendant-or-self::iframe[starts-with(@src, "https://www.google.com/recaptcha/")]', node)) { this.setupIFrame(iframe); } if (textarea = $.x('./descendant-or-self::textarea', node)) { this.setupTextArea(textarea); } } } }, setupIFrame(iframe) { let needle; if (!doc.contains(iframe)) { return; } Captcha.replace.iframe(iframe); $.addClass(QR.nodes.el, 'captcha-open'); this.fixQRPosition(); $.on(iframe, 'load', this.fixQRPosition); if (d.activeElement === this.nodes.counter) { iframe.focus(); } if (['blink', 'edge'].includes($.engine) && (needle = iframe.parentNode, $$('#qr .captcha-container > div > div:first-of-type').includes(needle))) { return $.on(iframe.parentNode, 'scroll', function () { return this.scrollTop = 0; }); } }, fixQRPosition() { if (QR.nodes.el.getBoundingClientRect().bottom > doc.clientHeight) { QR.nodes.el.style.top = ''; return QR.nodes.el.style.bottom = '0px'; } }, setupTextArea(textarea) { return $.one(textarea, 'input', () => this.save(true)); }, destroy() { if (!this.isEnabled) { return; } delete this.timeouts.destroy; $.rmClass(QR.nodes.el, 'captcha-open'); if (this.nodes.container) { $.global('resetCaptcha'); $.rm(this.nodes.container); return delete this.nodes.container; } }, getOne(isReply) { return Captcha.cache.getOne(isReply); }, save(pasted, token) { Captcha.cache.save({ response: token || $('textarea', this.nodes.container).value, timeout: Date.now() + this.lifetime }); const focus = (d.activeElement?.nodeName === 'IFRAME') && /https?:\/\/www\.google\.com\/recaptcha\//.test(d.activeElement.src); if (Captcha.cache.needed()) { if (focus) { if (QR.cooldown.auto || Conf['Post on Captcha Completion']) { this.nodes.counter.focus(); } else { QR.nodes.status.focus(); } } this.reload(); } else { if (pasted) { this.destroy(); } else { if (this.timeouts.destroy == null) { this.timeouts.destroy = setTimeout(this.destroy.bind(this), 3 * SECOND); } } if (focus) { QR.nodes.status.focus(); } } if (Conf['Post on Captcha Completion'] && !QR.cooldown.auto) { return QR.submit(); } }, count() { const count = Captcha.cache.getCount(); const loading = Captcha.cache.submitCB ? '...' : ''; this.nodes.counter.textContent = count || loading ? `Captchas: ${count}${loading}` : 'Get Captcha'; return this.moreNeeded(); }, reload() { if ($('iframe[src^="https://www.google.com/recaptcha/api/fallback?"]', this.nodes.container)) { this.destroy(); return this.setup(false, true); } else { const container = $('#qr .captcha-container'); if (window.grecaptcha && container?.dataset.widgetID != null) { window.grecaptcha.reset(container.dataset.widgetID); } } }, occupied() { return !!this.nodes.container && !this.timeouts.destroy; } } }; class VideoStripper { static async stripAudio(file) { try { const buffer = await file.arrayBuffer(); const uint8 = new Uint8Array(buffer); const view = new DataView(buffer); let patched = false; if (file.type === 'video/mp4' || file.name.toLowerCase().endsWith('.mp4')) { patched = this.stripMp4(uint8, view); } else if (file.type === 'video/webm' || file.name.toLowerCase().endsWith('.webm')) { patched = this.stripWebm(uint8); } if (patched) { return new File([buffer], file.name, { type: file.type }); } } catch (error) { console.warn('Failed to strip audio from video:', error); } return file; } static async hasAudioTrack(file) { try { const buffer = await file.arrayBuffer(); const uint8 = new Uint8Array(buffer); if (file.type === 'video/mp4' || file.name.toLowerCase().endsWith('.mp4')) { return this.stripMp4(uint8, new DataView(buffer), true); } if (file.type === 'video/webm' || file.name.toLowerCase().endsWith('.webm')) { return this.stripWebm(uint8, true); } } catch (error) { console.warn('Failed to detect audio track in video:', error); } return null; } static stripMp4(uint8, view, detectOnly = false) { let offset = 0; let stripped = false; const utf8Decoder = new TextDecoder('utf8'); while (offset + 8 <= uint8.length) { let size = view.getUint32(offset, false); let boxOffset = offset; if (size === 1) { if (offset + 16 > uint8.length) break; size = view.getUint32(offset + 12, false); boxOffset += 8; } else if (size === 0) { size = uint8.length - offset; } if (size < 8) break; const boxEnd = offset + size; if (boxEnd > uint8.length) break; const type = utf8Decoder.decode(uint8.subarray(boxOffset + 4, boxOffset + 8)); if (type !== 'moov') { offset = boxEnd; continue; } let moovOffset = boxOffset + 8; const moovEnd = boxEnd; while (moovOffset + 8 <= moovEnd) { let boxSize = view.getUint32(moovOffset, false); if (boxSize === 0) { boxSize = moovEnd - moovOffset; } if (boxSize < 8 || (moovOffset + boxSize > moovEnd)) break; const boxType = utf8Decoder.decode(uint8.subarray(moovOffset + 4, moovOffset + 8)); if (boxType === 'trak') { let isAudio = false; let trakOffset = moovOffset + 8; const trakEnd = moovOffset + boxSize; while (trakOffset + 8 <= trakEnd) { let tBoxSize = view.getUint32(trakOffset, false); if (tBoxSize === 0) { tBoxSize = trakEnd - trakOffset; } if (tBoxSize < 8 || (trakOffset + tBoxSize > trakEnd)) break; const tBoxType = utf8Decoder.decode(uint8.subarray(trakOffset + 4, trakOffset + 8)); if (tBoxType === 'mdia') { let mdiaOffset = trakOffset + 8; const mdiaEnd = trakOffset + tBoxSize; while (mdiaOffset + 8 <= mdiaEnd) { let mBoxSize = view.getUint32(mdiaOffset, false); if (mBoxSize === 0) { mBoxSize = mdiaEnd - mdiaOffset; } if (mBoxSize < 8 || (mdiaOffset + mBoxSize > mdiaEnd)) break; const mBoxType = utf8Decoder.decode(uint8.subarray(mdiaOffset + 4, mdiaOffset + 8)); if (mBoxType === 'hdlr' && mdiaOffset + 20 <= mdiaEnd) { const handlerType = utf8Decoder.decode(uint8.subarray(mdiaOffset + 16, mdiaOffset + 20)); if (handlerType === 'soun') { isAudio = true; break; } } mdiaOffset += mBoxSize; } } trakOffset += tBoxSize; } if (isAudio) { if (detectOnly) { return true; } uint8[moovOffset + 4] = 0x66; uint8[moovOffset + 5] = 0x72; uint8[moovOffset + 6] = 0x65; uint8[moovOffset + 7] = 0x65; stripped = true; } } moovOffset += boxSize; } offset = boxEnd; } return stripped; } static stripWebm(uint8, detectOnly = false) { let offset = 0; let stripped = false; const audioTracks = new Set(); const readVint = (off) => { if (off >= uint8.length) return { val: 0, length: 1 }; const first = uint8[off]; let mask = 0x80; let length = 1; while (!(first & mask) && length < 8) { mask >>= 1; length++; } let val = first & ~mask; for (let i = 1; i < length; i++) { if (off + i >= uint8.length) break; val = (val << 8) | uint8[off + i]; } return { val, length }; }; const skipEbmlElement = (from) => { if (from >= uint8.length) return uint8.length; let idLength = 1; while (idLength < 8 && !(uint8[from] & (0x80 >> (idLength - 1)))) { idLength++; } const sizeInfo = readVint(from + idLength); return from + idLength + sizeInfo.length + sizeInfo.val; }; const readUint = (off, length) => { let val = 0; for (let i = 0; i < length; i++) { if (off + i >= uint8.length) break; val = (val * 256) + uint8[off + i]; } return val; }; const stripClusterAudio = (from, end) => { let curr = from; while (curr + 1 <= end && curr < uint8.length) { const id = uint8[curr]; if (id === 0xA3) { const sizeInfo = readVint(curr + 1); const dataOffset = curr + 1 + sizeInfo.length; const trackInfo = readVint(dataOffset); if (audioTracks.has(trackInfo.val)) { uint8[curr] = 0xEC; stripped = true; } curr = dataOffset + sizeInfo.val; } else if (id === 0xA0) { const sizeInfo = readVint(curr + 1); const dataOffset = curr + 1 + sizeInfo.length; const groupEnd = dataOffset + sizeInfo.val; let isAudio = false; let inner = dataOffset; while (inner < groupEnd && inner < uint8.length) { if (uint8[inner] === 0xA1) { const blockSizeInfo = readVint(inner + 1); const trackInfo = readVint(inner + 1 + blockSizeInfo.length); if (audioTracks.has(trackInfo.val)) isAudio = true; inner += 1 + blockSizeInfo.length + blockSizeInfo.val; } else { inner = skipEbmlElement(inner); } } if (isAudio) { uint8[curr] = 0xEC; stripped = true; } curr = groupEnd; } else { curr = skipEbmlElement(curr); } } }; while (offset + 4 <= uint8.length) { if (uint8[offset] === 0x1A && uint8[offset + 1] === 0x45 && uint8[offset + 2] === 0xDF && uint8[offset + 3] === 0xA3) { const sizeInfo = readVint(offset + 4); offset += 4 + sizeInfo.length + sizeInfo.val; continue; } if (!(uint8[offset] === 0x18 && uint8[offset + 1] === 0x53 && uint8[offset + 2] === 0x80 && uint8[offset + 3] === 0x67)) { break; } const sizeInfo = readVint(offset + 4); offset += 4 + sizeInfo.length; const segmentEnd = offset + sizeInfo.val; while (offset + 4 <= segmentEnd && offset + 4 <= uint8.length) { if (uint8[offset] === 0x16 && uint8[offset + 1] === 0x54 && uint8[offset + 2] === 0xAE && uint8[offset + 3] === 0x6B) { const tracksSizeInfo = readVint(offset + 4); let tracksOffset = offset + 4 + tracksSizeInfo.length; const tracksEnd = tracksOffset + tracksSizeInfo.val; while (tracksOffset < tracksEnd && tracksOffset < uint8.length) { if (uint8[tracksOffset] === 0xAE) { const entryStart = tracksOffset; const entrySizeInfo = readVint(tracksOffset + 1); const entryDataOffset = tracksOffset + 1 + entrySizeInfo.length; const entryEnd = entryDataOffset + entrySizeInfo.val; let trackType = -1; let trackNumber = -1; let curr = entryDataOffset; while (curr < entryEnd && curr < uint8.length) { if (uint8[curr] === 0x83) { const sizeInfo = readVint(curr + 1); trackType = readUint(curr + 1 + sizeInfo.length, sizeInfo.val); curr += 1 + sizeInfo.length + sizeInfo.val; } else if (uint8[curr] === 0xD7) { const sizeInfo = readVint(curr + 1); trackNumber = readUint(curr + 1 + sizeInfo.length, sizeInfo.val); curr += 1 + sizeInfo.length + sizeInfo.val; } else { curr = skipEbmlElement(curr); } } if (trackType === 2) { if (detectOnly) { return true; } if (trackNumber >= 0) audioTracks.add(trackNumber); uint8[entryStart] = 0xEC; stripped = true; } tracksOffset = entryEnd; } else { tracksOffset = skipEbmlElement(tracksOffset); } } offset = tracksEnd; } else if (uint8[offset] === 0x1F && uint8[offset + 1] === 0x43 && uint8[offset + 2] === 0xB6 && uint8[offset + 3] === 0x75) { const clusterSizeInfo = readVint(offset + 4); const clusterDataOffset = offset + 4 + clusterSizeInfo.length; let clusterEnd = clusterDataOffset + clusterSizeInfo.val; if (clusterEnd > segmentEnd || clusterEnd > uint8.length) { clusterEnd = Math.min(segmentEnd, uint8.length); } if (audioTracks.size) stripClusterAudio(clusterDataOffset, clusterEnd); offset = clusterDataOffset + clusterSizeInfo.val; } else { offset = skipEbmlElement(offset); } } } return stripped; } } const DB_NAME = 'fourchanXT'; const STORE = 'qrDraftFiles'; const VERSION = 1; let dbPromise = null; function openDB() { if (dbPromise) { return dbPromise; } dbPromise = new Promise(resolve => { try { if (typeof indexedDB === 'undefined') { resolve(null); return; } const req = indexedDB.open(DB_NAME, VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE)) { db.createObjectStore(STORE); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => resolve(null); req.onblocked = () => resolve(null); } catch { resolve(null); } }); return dbPromise; } function store(db, mode) { return db.transaction(STORE, mode).objectStore(STORE); } const QRFileStore = { async put(id, blob) { const db = await openDB(); if (!db) { return false; } return new Promise(resolve => { try { const req = store(db, 'readwrite').put(blob, id); req.onsuccess = () => resolve(true); req.onerror = () => resolve(false); } catch { resolve(false); } }); }, async get(id) { const db = await openDB(); if (!db) { return undefined; } return new Promise(resolve => { try { const req = store(db, 'readonly').get(id); req.onsuccess = () => resolve(req.result); req.onerror = () => resolve(undefined); } catch { resolve(undefined); } }); }, async delete(ids) { const list = Array.isArray(ids) ? ids : [ids]; if (!list.length) { return; } const db = await openDB(); if (!db) { return; } return new Promise(resolve => { try { const os = store(db, 'readwrite'); for (const id of list) { os.delete(id); } os.transaction.oncomplete = () => resolve(); os.transaction.onerror = () => resolve(); os.transaction.onabort = () => resolve(); } catch { resolve(); } }); }, async keys() { const db = await openDB(); if (!db) { return []; } return new Promise(resolve => { try { const req = store(db, 'readonly').getAllKeys(); req.onsuccess = () => resolve((req.result || []).map(String)); req.onerror = () => resolve([]); } catch { resolve([]); } }); }, }; var DogiriPage = `
Dōgiri (WIP) Reset Save Projects
`; const MIN_REGION_LENGTH = 0.1; const MIN_ADD_GAP = 0.4; const CAPTION_Y_MAX = 90; const POSITION_PRESETS = { top: 8, bottom: 82 }; const SIZE_FRACTIONS = { small: 0.045, medium: 0.065, large: 0.09 }; const CAPTION_LINE_HEIGHT = 1.2; const CAPTION_MAX_WIDTH_FRACTION = 0.94; function createState(duration) { const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0; return { duration: safeDuration, regions: safeDuration > 0 ? [{ id: 1, start: 0, end: safeDuration }] : [], captions: [], stripAudio: false, nextId: 2, }; } function resetState(state) { Object.assign(state, createState(state.duration)); } function formatTime(seconds, tenths = false) { if (!Number.isFinite(seconds) || seconds < 0) { seconds = 0; } if (tenths) { const total = Math.round(seconds * 10); const whole = Math.floor(total / 10); return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}.${total % 10}`; } const whole = Math.floor(seconds); return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`; } function parseTime(text) { const trimmed = text.trim(); if (!trimmed) { return null; } const parts = trimmed.split(':'); if (parts.length > 3) { return null; } let seconds = 0; for (let i = 0; i < parts.length; i++) { const pattern = i === parts.length - 1 ? /^(\d+(\.\d+)?|\.\d+)$/ : /^\d+$/; if (!pattern.test(parts[i])) { return null; } seconds = seconds * 60 + parseFloat(parts[i]); } return seconds; } const clampValue = (value, lo, hi) => Math.min(Math.max(value, lo), hi); function regionIndex(state, id) { return state.regions.findIndex(region => region.id === id); } function addRegion(state) { if (state.duration <= 0) { return null; } let gapStart = 0; let bestStart = 0; let bestLength = 0; for (const region of state.regions) { if (region.start - gapStart > bestLength) { bestLength = region.start - gapStart; bestStart = gapStart; } gapStart = region.end; } if (state.duration - gapStart > bestLength) { bestLength = state.duration - gapStart; bestStart = gapStart; } if (bestLength < MIN_ADD_GAP) { return null; } const inset = bestLength / 4; const region = { id: state.nextId++, start: bestStart + inset, end: bestStart + bestLength - inset, }; state.regions.push(region); state.regions.sort((a, b) => a.start - b.start); return region; } function removeRegion(state, id) { state.regions = state.regions.filter(region => region.id !== id); } function removeRegions(state, ids) { const drop = new Set(ids); const before = state.regions.length; state.regions = state.regions.filter(region => !drop.has(region.id)); return before - state.regions.length; } function keepRegions(state, ids) { const keep = new Set(ids); const before = state.regions.length; state.regions = state.regions.filter(region => keep.has(region.id)); return before - state.regions.length; } function mergeRegions(state, ids) { const chosen = new Set(ids); const selected = state.regions.filter(region => chosen.has(region.id)); if (selected.length < 2) { return null; } const start = Math.min(...selected.map(region => region.start)); const end = Math.max(...selected.map(region => region.end)); state.regions = state.regions.filter(region => region.end <= start || region.start >= end); const merged = { id: state.nextId++, start, end }; state.regions.push(merged); state.regions.sort((a, b) => a.start - b.start); return merged; } function moveRegionEdge(state, id, edge, time) { const index = regionIndex(state, id); if (index < 0) { return; } const region = state.regions[index]; if (edge === 'start') { const lo = index > 0 ? state.regions[index - 1].end : 0; const hi = region.end - MIN_REGION_LENGTH; region.start = clampValue(time, lo, Math.max(lo, hi)); } else { const hi = index < state.regions.length - 1 ? state.regions[index + 1].start : state.duration; const lo = region.start + MIN_REGION_LENGTH; region.end = clampValue(time, Math.min(lo, hi), hi); } } function moveRegionFree(state, id, start) { const region = state.regions.find(existing => existing.id === id); if (!region) { return; } const length = region.end - region.start; region.start = clampValue(start, 0, Math.max(0, state.duration - length)); region.end = region.start + length; state.regions.sort((a, b) => a.start - b.start); } function settleRegion(state, id, fallback) { const region = state.regions.find(existing => existing.id === id); if (!region) { return; } const length = region.end - region.start; const overlapsOther = () => state.regions.find(other => other.id !== id && other.start < region.end && other.end > region.start); let guard = state.regions.length + 2; while (guard-- > 0) { const other = overlapsOther(); if (!other) { break; } const center = (region.start + region.end) / 2; const otherCenter = (other.start + other.end) / 2; if (center < otherCenter) { region.end = other.start; region.start = region.end - length; } else { region.start = other.end; region.end = region.start + length; } if (region.start < 0) { region.start = 0; region.end = length; } else if (region.end > state.duration) { region.end = state.duration; region.start = state.duration - length; } } if (overlapsOther() || region.start < 0 || region.end > state.duration) { region.start = fallback.start; region.end = fallback.end; } state.regions.sort((a, b) => a.start - b.start); } function totalClipped(state) { return state.regions.reduce((sum, region) => sum + (region.end - region.start), 0); } function inRegion(state, time) { return state.regions.some(region => time >= region.start && time < region.end); } function nextRegionStart(state, time) { for (const region of state.regions) { if (region.start > time) { return region.start; } } return null; } function addCaption(state, start, end, text, y, size) { text = text.trim(); start = clampValue(start, 0, state.duration); end = clampValue(end, 0, state.duration); if (start > end) { [start, end] = [end, start]; } if (!text || end - start <= 0) { return null; } const caption = { id: state.nextId++, start, end, text, y: clampValue(y, 0, CAPTION_Y_MAX), size, }; state.captions.push(caption); return caption; } function updateCaption(state, id, text, size, range, y) { const caption = state.captions.find(existing => existing.id === id); text = text.trim(); if (!caption || !text) { return null; } caption.text = text; caption.size = size; if (y != null) { caption.y = clampValue(y, 0, CAPTION_Y_MAX); } if (range) { let start = clampValue(range.start, 0, state.duration); let end = clampValue(range.end, 0, state.duration); if (start > end) { [start, end] = [end, start]; } if (end - start > 0) { caption.start = start; caption.end = end; } } return caption; } function setCaptionY(state, id, y) { const caption = state.captions.find(existing => existing.id === id); if (caption) { caption.y = clampValue(y, 0, CAPTION_Y_MAX); } } function moveCaptionEdge(state, id, edge, time) { const caption = state.captions.find(existing => existing.id === id); if (!caption) { return; } if (edge === 'start') { caption.start = clampValue(time, 0, Math.max(0, caption.end - MIN_REGION_LENGTH)); } else { caption.end = clampValue(time, Math.min(caption.start + MIN_REGION_LENGTH, state.duration), state.duration); } } function moveCaption(state, id, start) { const caption = state.captions.find(existing => existing.id === id); if (!caption) { return; } const length = caption.end - caption.start; caption.start = clampValue(start, 0, Math.max(0, state.duration - length)); caption.end = caption.start + length; } function removeCaption(state, id) { state.captions = state.captions.filter(caption => caption.id !== id); } function captionsAt(state, time) { return state.captions.filter(caption => time >= caption.start && time < caption.end); } function captionLaneLevels(state) { const levels = new Map(); const rowEnds = []; const sorted = [...state.captions].sort((a, b) => a.start - b.start || a.id - b.id); for (const caption of sorted) { let row = rowEnds.findIndex(end => caption.start >= end); if (row < 0) { row = rowEnds.length; rowEnds.push(0); } rowEnds[row] = caption.end; levels.set(caption.id, row); } return { levels, rows: Math.max(1, rowEnds.length) }; } const CAPTION_Y_CLEARANCE = 9; function freeCaptionY(state, start, end, y) { const collides = (candidate) => state.captions.some(caption => caption.start < end && caption.end > start && Math.abs(caption.y - candidate) < CAPTION_Y_CLEARANCE); if (!collides(y)) { return y; } const step = y >= 50 ? -CAPTION_Y_CLEARANCE : CAPTION_Y_CLEARANCE; for (let candidate = y + step; candidate >= 0 && candidate <= CAPTION_Y_MAX; candidate += step) { if (!collides(candidate)) { return candidate; } } return y; } function wrapCaptionLines(measure, text, maxWidth) { const lines = []; let line = ''; for (const word of text.split(/\s+/).filter(Boolean)) { const candidate = line ? `${line} ${word}` : word; if (line && measure(candidate) > maxWidth) { lines.push(line); line = word; } else { line = candidate; } } if (line) { lines.push(line); } return lines; } function captionBlockTop(y, blockHeightPct) { return Math.max(0, Math.min(y, 98 - blockHeightPct)); } function nearestIndex(values, target) { let best = -1; let bestDistance = Infinity; values.forEach((value, index) => { const distance = Math.abs(value - target); if (distance < bestDistance) { bestDistance = distance; best = index; } }); return best; } function serializeProject(state, name, savedAt, video, quality) { return { name, savedAt, video, quality, duration: state.duration, regions: state.regions.map(region => ({ start: region.start, end: region.end })), captions: state.captions.map(caption => ({ start: caption.start, end: caption.end, text: caption.text, y: caption.y, size: caption.size, })), stripAudio: state.stripAudio, }; } const CAPTION_SIZES = ['small', 'medium', 'large']; function applyProject(state, project) { const { duration } = state; state.regions = []; state.captions = []; state.nextId = 1; let prevEnd = 0; const regions = [...(project.regions || [])].sort((a, b) => a.start - b.start); for (const raw of regions) { const start = Math.max(clampValue(+raw.start || 0, 0, duration), prevEnd); const end = clampValue(+raw.end || 0, 0, duration); if (end - start < MIN_REGION_LENGTH) { continue; } state.regions.push({ id: state.nextId++, start, end }); prevEnd = end; } for (const raw of project.captions || []) { const start = clampValue(+raw.start || 0, 0, duration); const end = clampValue(+raw.end || 0, 0, duration); const text = String(raw.text || '').trim(); if (!text || end - start <= 0) { continue; } state.captions.push({ id: state.nextId++, start, end, text, y: clampValue(+raw.y || 0, 0, CAPTION_Y_MAX), size: CAPTION_SIZES.includes(raw.size) ? raw.size : 'medium', }); } state.stripAudio = !!project.stripAudio; } const UNDO_LIMIT = 100; function createHistory() { return { undo: [], redo: [] }; } function snapshot(state) { return { regions: state.regions.map(region => ({ ...region })), captions: state.captions.map(caption => ({ ...caption })), stripAudio: state.stripAudio, }; } function applySnapshot(state, snap) { state.regions = snap.regions.map(region => ({ ...region })); state.captions = snap.captions.map(caption => ({ ...caption })); state.stripAudio = snap.stripAudio; } function sameSnapshot(a, b) { return JSON.stringify(a) === JSON.stringify(b); } function commitHistory(history, before, state) { if (sameSnapshot(before, snapshot(state))) { return false; } history.undo.push(before); if (history.undo.length > UNDO_LIMIT) { history.undo.shift(); } history.redo.length = 0; return true; } function undo(history, state) { const snap = history.undo.pop(); if (!snap) { return false; } history.redo.push(snapshot(state)); applySnapshot(state, snap); return true; } function redo(history, state) { const snap = history.redo.pop(); if (!snap) { return false; } history.undo.push(snapshot(state)); applySnapshot(state, snap); return true; } function clipTimelineOffsets(regions) { const offsets = []; let acc = 0; for (const region of regions) { offsets.push(acc); acc += Math.max(0, region.end - region.start); } return offsets; } function opusHead(channels, sampleRate) { const head = new Uint8Array(19); head.set(new TextEncoder().encode('OpusHead'), 0); head[8] = 1; head[9] = channels; const view = new DataView(head.buffer); view.setUint16(10, 312, true); view.setUint32(12, sampleRate, true); return head; } const VIDEO_CODEC_CANDIDATES = [ { codec: 'vp09.00.50.08', codecId: 'V_VP9' }, { codec: 'vp09.00.40.08', codecId: 'V_VP9' }, { codec: 'vp8', codecId: 'V_VP8' }, ]; async function webCodecsSupport(width, height, keepAudio, scope = window) { if (typeof scope.VideoEncoder?.isConfigSupported !== 'function' || !scope.VideoFrame) { return null; } if (keepAudio) { if (typeof scope.AudioEncoder?.isConfigSupported !== 'function' || !scope.AudioData || !scope.OfflineAudioContext) { return null; } try { const audio = await scope.AudioEncoder.isConfigSupported({ codec: 'opus', sampleRate: 48000, numberOfChannels: 2, }); if (!audio?.supported) { return null; } } catch { return null; } } for (const candidate of VIDEO_CODEC_CANDIDATES) { try { const result = await scope.VideoEncoder.isConfigSupported({ codec: candidate.codec, width, height, bitrate: 1000000, }); if (result?.supported) { return candidate; } } catch { } } return null; } function regionAudioChunks(buffer, startSeconds, endSeconds, chunkFrames) { const sampleRate = buffer.sampleRate; const channels = Math.min(2, buffer.numberOfChannels); const first = Math.min(buffer.length, Math.max(0, Math.round(startSeconds * sampleRate))); const last = Math.min(buffer.length, Math.max(first, Math.round(endSeconds * sampleRate))); const chunks = []; for (let frame = first; frame < last; frame += chunkFrames) { const frames = Math.min(chunkFrames, last - frame); const data = new Float32Array(frames * channels); for (let channel = 0; channel < channels; channel++) { data.set(buffer.getChannelData(channel).subarray(frame, frame + frames), channel * frames); } chunks.push({ data, frames, offsetFrames: frame - first }); } return { channels, sampleRate, chunks }; } function concatBytes(parts) { let length = 0; for (const part of parts) { length += part.length; } const out = new Uint8Array(length); let off = 0; for (const part of parts) { out.set(part, off); off += part.length; } return out; } function idBytes(id) { const bytes = []; let value = id; while (value > 0) { bytes.unshift(value % 256); value = Math.floor(value / 256); } return Uint8Array.from(bytes); } function sizeVint(value) { let length = 1; while (value >= 2 ** (7 * length) - 1 && length < 8) { length++; } const bytes = new Uint8Array(length); let remaining = value; for (let i = length - 1; i > 0; i--) { bytes[i] = remaining % 256; remaining = Math.floor(remaining / 256); } bytes[0] = (0x80 >> (length - 1)) | remaining; return bytes; } function el(id, body) { const data = Array.isArray(body) ? concatBytes(body) : body; return concatBytes([idBytes(id), sizeVint(data.length), data]); } function uintEl(id, value) { const bytes = []; let remaining = Math.round(value); do { bytes.unshift(remaining % 256); remaining = Math.floor(remaining / 256); } while (remaining > 0); return el(id, Uint8Array.from(bytes)); } function strEl(id, text) { return el(id, new TextEncoder().encode(text)); } function floatEl(id, value) { const bytes = new Uint8Array(8); new DataView(bytes.buffer).setFloat64(0, value, false); return el(id, bytes); } function simpleBlock(track, relativeTs, keyframe, data) { const head = new Uint8Array(4); head[0] = 0x80 | track; new DataView(head.buffer).setInt16(1, relativeTs, false); head[3] = keyframe ? 0x80 : 0; return el(0xA3, [head, data]); } const CLUSTER_MAX_RELATIVE_MS = 32000; function buildWebm(input) { const header = el(0x1A45DFA3, [ uintEl(0x4286, 1), uintEl(0x42F7, 1), uintEl(0x42F2, 4), uintEl(0x42F3, 8), strEl(0x4282, 'webm'), uintEl(0x4287, 4), uintEl(0x4285, 2), ]); const info = el(0x1549A966, [ uintEl(0x2AD7B1, 1000000), floatEl(0x4489, input.durationMs), strEl(0x4D80, '4chan-neXT'), strEl(0x5741, '4chan-neXT'), ]); const trackEntries = [el(0xAE, [ uintEl(0xD7, 1), uintEl(0x73C5, 1), uintEl(0x83, 1), uintEl(0x9C, 0), strEl(0x86, input.video.codecId), el(0xE0, [uintEl(0xB0, input.video.width), uintEl(0xBA, input.video.height)]), ])]; if (input.audio) { trackEntries.push(el(0xAE, [ uintEl(0xD7, 2), uintEl(0x73C5, 2), uintEl(0x83, 2), uintEl(0x9C, 0), strEl(0x86, 'A_OPUS'), uintEl(0x56AA, 6500000), uintEl(0x56BB, 80000000), el(0x63A2, input.audio.codecPrivate), el(0xE1, [floatEl(0xB5, input.audio.sampleRate), uintEl(0x9F, input.audio.channels)]), ])); } const tracks = el(0x1654AE6B, trackEntries); const blocks = [ ...input.videoChunks.map(chunk => ({ ...chunk, track: 1 })), ...input.audioChunks.map(chunk => ({ ...chunk, track: 2 })), ].sort((a, b) => (a.timestampMs - b.timestampMs) || (a.track - b.track)); const clusters = []; let clusterTs = 0; let clusterBlocks = []; const flushCluster = () => { if (clusterBlocks.length) { clusters.push(el(0x1F43B675, [uintEl(0xE7, clusterTs), ...clusterBlocks])); } clusterBlocks = []; }; for (const block of blocks) { const ts = Math.max(0, Math.round(block.timestampMs)); if (clusterBlocks.length && ((block.track === 1 && block.keyframe) || ts - clusterTs > CLUSTER_MAX_RELATIVE_MS)) { flushCluster(); } if (!clusterBlocks.length) { clusterTs = ts; } clusterBlocks.push(simpleBlock(block.track, ts - clusterTs, block.keyframe, block.data)); } flushCluster(); return concatBytes([header, el(0x18538067, [info, tracks, ...clusters])]); } const RATE_STEPS = [1, 2, 4]; const RATE_CLEAN_STREAK = 24; const RATE_DROP_FACTOR = 1.6; const RATE_MAX_FAILURES = 2; function createRateController() { let step = 0; let minDelta = Infinity; let cleanStreak = 0; const failures = new Map(); return { rate: () => RATE_STEPS[step], onDelta(delta) { if (delta <= 0) { return { rate: RATE_STEPS[step], rewind: false }; } minDelta = Math.min(minDelta, delta); if (delta > minDelta * RATE_DROP_FACTOR) { cleanStreak = 0; if (step > 0) { const failed = RATE_STEPS[step]; failures.set(failed, (failures.get(failed) || 0) + 1); step--; return { rate: RATE_STEPS[step], rewind: true }; } return { rate: RATE_STEPS[step], rewind: false }; } cleanStreak++; if (cleanStreak >= RATE_CLEAN_STREAK && step < RATE_STEPS.length - 1 && (failures.get(RATE_STEPS[step + 1]) || 0) < RATE_MAX_FAILURES) { step++; cleanStreak = 0; } return { rate: RATE_STEPS[step], rewind: false }; }, }; } const KEYFRAME_INTERVAL_US = 4000000; const AUDIO_CHUNK_FRAMES = 9600; function toUint8(source) { if (source instanceof Uint8Array) { return new Uint8Array(source); } if (source instanceof ArrayBuffer) { return new Uint8Array(source.slice(0)); } return new Uint8Array(source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength)); } async function runWebCodecsCapture(opts) { const { video, canvas, regions, total, support } = opts; const scope = window; const offsets = clipTimelineOffsets(regions); let audioBuffer = null; if (opts.keepAudio) { const offline = new scope.OfflineAudioContext(2, 1, 48000); audioBuffer = await offline.decodeAudioData(await opts.file.arrayBuffer()); } let encodeError = null; const videoChunks = []; const encoder = new scope.VideoEncoder({ output: (chunk) => { const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data); videoChunks.push({ data, timestampMs: chunk.timestamp / 1000, keyframe: chunk.type === 'key' }); }, error: (error) => { encodeError = error instanceof Error ? error : new Error(String(error)); }, }); try { encoder.configure({ codec: support.codec, width: canvas.width, height: canvas.height, bitrate: opts.videoBps, framerate: 30, }); let lastOutputUs = -1; let lastKeyUs = 0; const encodeCanvasFrame = (regionIndex, sourceTime, forceKey = false) => { if (encodeError) { throw encodeError; } const region = regions[regionIndex]; const clamped = Math.min(Math.max(sourceTime, region.start), region.end); let timestamp = Math.round((offsets[regionIndex] + (clamped - region.start)) * 1000000); if (timestamp <= lastOutputUs) { timestamp = lastOutputUs + 1000; } const keyFrame = forceKey || lastOutputUs < 0 || timestamp - lastKeyUs >= KEYFRAME_INTERVAL_US; if (keyFrame) { lastKeyUs = timestamp; } lastOutputUs = timestamp; const frame = new scope.VideoFrame(canvas, { timestamp }); try { encoder.encode(frame, { keyFrame }); } finally { frame.close(); } }; video.muted = true; const rateControl = createRateController(); let done = 0; for (let i = 0; i < regions.length; i++) { const region = regions[i]; if (opts.isCancelled()) { break; } await opts.seekTo(region.start); if (opts.isCancelled()) { break; } opts.drawFrame(); encodeCanvasFrame(i, region.start, true); video.playbackRate = rateControl.rate(); await video.play(); let lastTime = -1; let stalledTicks = 0; let rewindsLeft = 4; while (!opts.isCancelled()) { await opts.nextFrame(video); if (video.currentTime >= region.end || video.ended) { break; } if (video.currentTime === lastTime) { if (++stalledTicks >= 12) { break; } continue; } if (lastTime >= 0) { const decision = rateControl.onDelta(video.currentTime - lastTime); if (video.playbackRate !== decision.rate) { video.playbackRate = decision.rate; } if (decision.rewind && rewindsLeft > 0) { rewindsLeft--; video.pause(); await opts.seekTo(lastTime); await video.play(); continue; } } lastTime = video.currentTime; stalledTicks = 0; opts.drawFrame(); encodeCanvasFrame(i, video.currentTime); opts.onProgress?.(Math.min(1, (done + (video.currentTime - region.start)) / total)); } video.pause(); done += region.end - region.start; } if (opts.isCancelled()) { return null; } let audio; const audioChunks = []; if (audioBuffer) { let audioError = null; let codecPrivate = null; const audioEncoder = new scope.AudioEncoder({ output: (chunk, metadata) => { const description = metadata?.decoderConfig?.description; if (description && !codecPrivate) { codecPrivate = toUint8(description); } const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data); audioChunks.push({ data, timestampMs: chunk.timestamp / 1000, keyframe: true }); }, error: (error) => { audioError = error instanceof Error ? error : new Error(String(error)); }, }); const channels = Math.min(2, audioBuffer.numberOfChannels); try { audioEncoder.configure({ codec: 'opus', sampleRate: audioBuffer.sampleRate, numberOfChannels: channels, bitrate: opts.audioBps, }); for (let i = 0; i < regions.length; i++) { const sliced = regionAudioChunks(audioBuffer, regions[i].start, regions[i].end, AUDIO_CHUNK_FRAMES); for (const part of sliced.chunks) { if (audioError) { throw audioError; } const data = new scope.AudioData({ format: 'f32-planar', sampleRate: sliced.sampleRate, numberOfFrames: part.frames, numberOfChannels: sliced.channels, timestamp: Math.round((offsets[i] + part.offsetFrames / sliced.sampleRate) * 1000000), data: part.data, }); audioEncoder.encode(data); data.close(); } } await audioEncoder.flush(); if (audioError) { throw audioError; } } finally { try { if (audioEncoder.state !== 'closed') { audioEncoder.close(); } } catch { } } audio = { sampleRate: audioBuffer.sampleRate, channels, codecPrivate: codecPrivate || opusHead(channels, audioBuffer.sampleRate), }; } await encoder.flush(); if (encodeError) { throw encodeError; } if (!videoChunks.length) { throw new Error('Encoding produced no frames.'); } const bytes = buildWebm({ durationMs: total * 1000, video: { codecId: support.codecId, width: canvas.width, height: canvas.height }, audio, videoChunks, audioChunks, }); return new Blob([bytes], { type: 'video/webm' }); } finally { video.playbackRate = 1; try { if (encoder.state !== 'closed') { encoder.close(); } } catch { } } } const AUDIO_BPS = 128000; const QUALITY_TIERS = { high: { maxHeight: Infinity, perPixel: 6, cap: 12000000 }, medium: { maxHeight: Infinity, perPixel: 4, cap: 8000000 }, low: { maxHeight: 720, perPixel: 3, cap: 4000000 }, min: { maxHeight: 480, perPixel: 2.5, cap: 2000000 }, }; function exportSettings(width, height, quality) { const tier = QUALITY_TIERS[quality] || QUALITY_TIERS.medium; const scale = height > 0 ? Math.min(1, tier.maxHeight / height) : 1; const outWidth = Math.max(2, Math.round((width * scale) / 2) * 2); const outHeight = Math.max(2, Math.round((height * scale) / 2) * 2); const videoBps = Math.min(tier.cap, Math.max(500000, Math.round(outWidth * outHeight * tier.perPixel))); return { width: outWidth, height: outHeight, videoBps }; } const FIT_MIN_VIDEO_BPS = 300000; const FIT_SAFETY = 0.95; const FIT_MIN_PER_PIXEL = 2.5; const FIT_HEIGHT_STEPS = [Infinity, 720, 480, 360]; function fitSettings(width, height, clippedSeconds, targetBytes, keepAudio, calibration = 1) { const audioBps = keepAudio ? AUDIO_BPS : 0; const budgetBps = clippedSeconds > 0 ? ((targetBytes * 8) / clippedSeconds) * FIT_SAFETY / (calibration > 0 ? calibration : 1) - audioBps : 0; let outWidth = 2; let outHeight = 2; for (const maxHeight of FIT_HEIGHT_STEPS) { const scale = height > 0 ? Math.min(1, maxHeight / height) : 1; outWidth = Math.max(2, Math.round((width * scale) / 2) * 2); outHeight = Math.max(2, Math.round((height * scale) / 2) * 2); if (budgetBps >= outWidth * outHeight * FIT_MIN_PER_PIXEL) { break; } } const cap = Math.min(12000000, outWidth * outHeight * 6); const videoBps = Math.max(FIT_MIN_VIDEO_BPS, Math.min(cap, Math.round(budgetBps))); return { width: outWidth, height: outHeight, videoBps, reachable: budgetBps >= FIT_MIN_VIDEO_BPS }; } function exportSignature(opts) { return JSON.stringify({ regions: opts.regions .filter(region => region.end - region.start > 0) .map(region => [region.start, region.end]) .sort((a, b) => a[0] - b[0]), captions: opts.captions.map(caption => [caption.start, caption.end, caption.text, caption.y, caption.size]), keepAudio: opts.keepAudio, quality: opts.quality, ...(opts.quality === 'fit' ? { fitTargetBytes: opts.fitTargetBytes || 0 } : {}), }); } const EXPORT_CANCELLED = 'dogiri-export-cancelled'; function nextVideoFrame(video, timeoutMs = 250) { return new Promise(resolve => { let timer = 0; let settled = false; const finish = () => { if (settled) { return; } settled = true; clearTimeout(timer); video.removeEventListener('ended', finish); resolve(); }; timer = window.setTimeout(finish, timeoutMs); video.addEventListener('ended', finish, { once: true }); const rvfc = video.requestVideoFrameCallback; if (typeof rvfc === 'function') { rvfc.call(video, finish); } else { requestAnimationFrame(finish); } }); } function exportClips(opts) { let cancelled = false; const promise = (async () => { const regions = opts.regions .map(region => ({ ...region })) .filter(region => region.end - region.start > 0) .sort((a, b) => a.start - b.start); if (!regions.length) { throw new Error('No clips to export.'); } const total = regions.reduce((sum, region) => sum + (region.end - region.start), 0); const url = URL.createObjectURL(opts.file); const video = document.createElement('video'); video.preload = 'auto'; video.playsInline = true; video.src = url; let audioCtx = null; const cleanup = () => { video.pause(); video.removeAttribute('src'); video.load(); URL.revokeObjectURL(url); audioCtx?.close().catch(() => { }); }; try { await new Promise((resolve, reject) => { if (video.readyState >= 1) { resolve(); return; } video.addEventListener('loadedmetadata', () => resolve(), { once: true }); video.addEventListener('error', () => reject(new Error('Could not decode the video.')), { once: true }); }); if (!(video.videoWidth && video.videoHeight)) { throw new Error('Video has no visible frames.'); } const settings = opts.quality === 'fit' ? fitSettings(video.videoWidth, video.videoHeight, total, opts.fitTargetBytes || 0, opts.keepAudio, opts.fitCalibration) : exportSettings(video.videoWidth, video.videoHeight, opts.quality); const canvas = document.createElement('canvas'); canvas.width = settings.width; canvas.height = settings.height; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Canvas is unavailable.'); } ctx.imageSmoothingQuality = 'high'; const seekTo = (time) => new Promise(resolve => { if (Math.abs(video.currentTime - time) < 0.001) { if (video.readyState >= 2) { resolve(); return; } video.addEventListener('canplay', () => resolve(), { once: true }); return; } video.addEventListener('seeked', () => resolve(), { once: true }); video.currentTime = time; }); const drawFrame = () => { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const time = video.currentTime; for (const caption of opts.captions) { if (time < caption.start || time >= caption.end) { continue; } const px = Math.max(12, Math.round(canvas.height * SIZE_FRACTIONS[caption.size])); ctx.font = `bold ${px}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.lineWidth = Math.max(2, px / 8); ctx.lineJoin = 'round'; ctx.strokeStyle = '#000'; ctx.fillStyle = '#fff'; const x = canvas.width / 2; const maxWidth = canvas.width * CAPTION_MAX_WIDTH_FRACTION; const lines = wrapCaptionLines(text => ctx.measureText(text).width, caption.text, maxWidth); const lineHeight = px * CAPTION_LINE_HEIGHT; const blockHeightPct = ((lines.length * lineHeight) / canvas.height) * 100; const top = (captionBlockTop(caption.y, blockHeightPct) / 100) * canvas.height; lines.forEach((line, index) => { const y = top + index * lineHeight; ctx.strokeText(line, x, y, maxWidth); ctx.fillText(line, x, y, maxWidth); }); } }; const support = await webCodecsSupport(settings.width, settings.height, opts.keepAudio); if (support) { try { const muxed = await runWebCodecsCapture({ video, canvas, regions, total, file: opts.file, keepAudio: opts.keepAudio, videoBps: settings.videoBps, audioBps: AUDIO_BPS, support, drawFrame, seekTo, nextFrame: nextVideoFrame, isCancelled: () => cancelled, onProgress: opts.onProgress, }); if (cancelled || !muxed) { throw new Error(EXPORT_CANCELLED); } if (!muxed.size) { throw new Error('Encoding produced no data.'); } opts.onProgress?.(1); return { blob: muxed, durationOk: await probeDuration(muxed), engine: 'webcodecs' }; } catch (error) { if (cancelled || error?.message === EXPORT_CANCELLED) { throw error; } } } let stream = canvas.captureStream(0); const videoTrack = stream.getVideoTracks()[0]; let captureFrame = null; if (typeof videoTrack?.requestFrame === 'function') { captureFrame = () => videoTrack.requestFrame(); } else if (typeof stream.requestFrame === 'function') { captureFrame = () => stream.requestFrame(); } else { stream = canvas.captureStream(30); } if (opts.keepAudio) { try { video.muted = false; audioCtx = new AudioContext(); await audioCtx.resume(); const source = audioCtx.createMediaElementSource(video); const dest = audioCtx.createMediaStreamDestination(); source.connect(dest); for (const track of dest.stream.getAudioTracks()) { stream.addTrack(track); } } catch { video.muted = true; } } else { video.muted = true; } const codecSuffix = opts.keepAudio ? ',opus' : ''; const mimeType = [ `video/webm;codecs=vp9${codecSuffix}`, `video/webm;codecs=vp8${codecSuffix}`, 'video/webm', ].find(candidate => window.MediaRecorder?.isTypeSupported?.(candidate)) || 'video/webm'; const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: settings.videoBps, audioBitsPerSecond: AUDIO_BPS, }); const chunks = []; recorder.addEventListener('dataavailable', e => { if (e.data?.size) { chunks.push(e.data); } }); const drawAndCapture = () => { drawFrame(); captureFrame?.(); }; const playCurrentRegion = async () => { try { await video.play(); } catch { video.muted = true; await video.play(); } }; let done = 0; for (let i = 0; i < regions.length; i++) { const region = regions[i]; if (cancelled) { break; } await seekTo(region.start); if (cancelled) { break; } drawAndCapture(); await playCurrentRegion(); if (i === 0) { recorder.start(); } else { recorder.resume(); } let lastTime = -1; let stalledTicks = 0; while (!cancelled) { await nextVideoFrame(video); if (video.currentTime >= region.end || video.ended) { break; } if (video.currentTime === lastTime) { if (++stalledTicks >= 12) { break; } continue; } lastTime = video.currentTime; stalledTicks = 0; drawAndCapture(); opts.onProgress?.(Math.min(1, (done + (video.currentTime - region.start)) / total)); } video.pause(); done += region.end - region.start; if (i < regions.length - 1 && !cancelled) { recorder.pause(); } } if (cancelled) { if (recorder.state !== 'inactive') { recorder.stop(); } throw new Error(EXPORT_CANCELLED); } const blob = await new Promise((resolve, reject) => { recorder.addEventListener('stop', () => { resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' })); }, { once: true }); recorder.addEventListener('error', () => reject(new Error('Recording failed.')), { once: true }); recorder.stop(); }); if (!blob.size) { throw new Error('Recording produced no data.'); } opts.onProgress?.(1); const patched = await patchWebmDuration(blob, total * 1000); return { blob: patched, durationOk: await probeDuration(patched), engine: 'mediarecorder' }; } finally { cleanup(); } })(); return { promise, cancel: () => { cancelled = true; } }; } function probeDuration(blob) { return new Promise(resolve => { const url = URL.createObjectURL(blob); const probe = document.createElement('video'); const finish = (ok) => { URL.revokeObjectURL(url); resolve(ok); }; probe.preload = 'metadata'; probe.addEventListener('loadedmetadata', () => { finish(Number.isFinite(probe.duration) && probe.duration > 0); }, { once: true }); probe.addEventListener('error', () => finish(false), { once: true }); probe.src = url; }); } function readIdLength(byte) { for (let i = 0; i < 4; i++) { if (byte & (0x80 >> i)) { return i + 1; } } return 4; } function readSizeVint(bytes, off) { const first = bytes[off]; let length = 1; let mask = 0x80; while (!(first & mask) && length < 8) { mask >>= 1; length++; } let value = first & (mask - 1); let allOnes = (first & (mask - 1)) === (mask - 1); for (let i = 1; i < length; i++) { value = value * 256 + bytes[off + i]; if (bytes[off + i] !== 0xFF) { allOnes = false; } } return { value, length, unknown: allOnes }; } function idAt(bytes, off) { const length = readIdLength(bytes[off]); let id = 0; for (let i = 0; i < length; i++) { id = id * 256 + bytes[off + i]; } return id; } async function patchWebmDuration(blob, durationMs) { try { const headSize = Math.min(blob.size, 1 << 20); const head = new Uint8Array(await blob.slice(0, headSize).arrayBuffer()); let off = 0; if (idAt(head, off) !== 0x1A45DFA3) { return blob; } let size = readSizeVint(head, off + 4); off += 4 + size.length + size.value; if (idAt(head, off) !== 0x18538067) { return blob; } size = readSizeVint(head, off + 4); const segmentSizeUnknown = size.unknown; off += 4 + size.length; while (off < head.length - 12) { const id = idAt(head, off); if (id === 0x1F43B675) { return blob; } const idLength = readIdLength(head[off]); const elSize = readSizeVint(head, off + idLength); const dataStart = off + idLength + elSize.length; if (id !== 0x1549A966) { off = dataStart + elSize.value; continue; } let timestampScale = 1000000; let durationOffset = -1; let durationLength = 0; let inner = dataStart; const dataEnd = dataStart + elSize.value; while (inner < dataEnd && inner < head.length - 2) { const innerId = idAt(head, inner); const innerIdLength = readIdLength(head[inner]); const innerSize = readSizeVint(head, inner + innerIdLength); const innerDataStart = inner + innerIdLength + innerSize.length; if (innerId === 0x2AD7B1) { let value = 0; for (let i = 0; i < innerSize.value; i++) { value = value * 256 + head[innerDataStart + i]; } if (value > 0) { timestampScale = value; } } else if (innerId === 0x4489) { durationOffset = innerDataStart; durationLength = innerSize.value; } inner = innerDataStart + innerSize.value; } const durationValue = (durationMs * 1000000) / timestampScale; if (durationOffset >= 0 && (durationLength === 8 || durationLength === 4)) { const view = new DataView(head.buffer); if (durationLength === 8) { view.setFloat64(durationOffset, durationValue, false); } else { view.setFloat32(durationOffset, durationValue, false); } return new Blob([head, blob.slice(headSize)], { type: blob.type }); } if (durationOffset >= 0) { return blob; } if (!segmentSizeUnknown) { return blob; } const durationElement = new Uint8Array(11); durationElement[0] = 0x44; durationElement[1] = 0x89; durationElement[2] = 0x88; new DataView(durationElement.buffer).setFloat64(3, durationValue, false); const newDataLength = elSize.value + durationElement.length; const sizeVint = new Uint8Array(8); sizeVint[0] = 0x01; let remaining = newDataLength; for (let i = 7; i >= 1; i--) { sizeVint[i] = remaining % 256; remaining = Math.floor(remaining / 256); } const infoId = head.subarray(off, off + idLength); return new Blob([ head.subarray(0, off), infoId, sizeVint, head.subarray(dataStart, dataEnd), durationElement, blob.slice(dataEnd), ], { type: blob.type }); } return blob; } catch { return blob; } } const ADD_CAPTION_LABEL = '+ Add caption'; const TIMELINE_HEIGHTS = { small: 62, medium: 86, large: 110 }; const Dogiri = { nodes: null, state: null, history: createHistory(), gestureSnapshot: null, measureCtx: null, stripToken: 0, stripCache: [], stripSource: null, stripFillTimer: 0, resizeDrag: null, fingerprintPromise: null, fingerprint: '', pendingProject: null, relinkProject: null, autosaveTimer: 0, lastAutosaveSignature: '', currentProjectName: '', sessionAcknowledged: false, objectURL: '', fileSize: 0, rafId: 0, editingCaptionId: 0, editingInitialPosition: 'bottom', selectedRegionId: 0, regionMenuVisible: false, checkedClipIds: new Set(), dragTarget: null, pendingClipSeek: null, laneDrag: null, overlayDrag: null, pendingRange: null, playingClips: false, audioStripped: false, hasAudio: true, maxSize: 0, file: null, exportJob: null, lastExport: null, onExportDone: null, confirmResolve: null, fileSwapped: false, calibration: {}, persistentCalibration: {}, exportTarget: 'qr', open(file, opts) { if (Dogiri.nodes) { Dogiri.close(); } Dogiri.audioStripped = !!opts?.audioStripped; Dogiri.file = (opts?.audioStripped && opts?.originalFile) || file; Dogiri.maxSize = opts?.maxSize || 0; Dogiri.onExportDone = opts?.onExportDone || null; Dogiri.calibration = {}; $.get('Dogiri Calibration', {}, (item) => { Dogiri.persistentCalibration = item['Dogiri Calibration'] || {}; Dogiri.renderStats(); }); $.get('Dogiri Size', {}, (item) => { const width = +item['Dogiri Size']?.width; if (width) { Dogiri.applyDialogWidth(width); } }); const el = $.el('div', { id: 'dogiri', innerHTML: DogiriPage }); const node = (query) => $(query, el); Dogiri.nodes = { el, filename: node('#dogiri-filename'), duration: node('#dogiri-duration'), reset: node('#dogiri-reset'), save: node('#dogiri-save'), projects: node('#dogiri-projects'), savePanel: node('#dogiri-save-panel'), saveName: node('#dogiri-save-name'), saveOk: node('#dogiri-save-ok'), saveCancel: node('#dogiri-save-cancel'), projectsMenu: node('#dogiri-projects-menu'), relink: node('#dogiri-relink'), close: node('#dogiri-close'), resize: node('#dogiri-resize'), video: node('#dogiri-video'), previewBox: node('#dogiri-preview-box'), captionOverlay: node('#dogiri-caption-overlay'), toasts: node('#dogiri-toasts'), play: node('#dogiri-play'), time: node('#dogiri-time'), stripAudio: node('#dogiri-strip-audio'), captionsToggle: node('#dogiri-captions-toggle'), clipList: node('#dogiri-clip-list'), clipPanel: node('#dogiri-clip-panel'), clipsAll: node('#dogiri-clips-all'), clipsCount: node('#dogiri-clips-count'), clipRows: node('#dogiri-clip-rows'), clipsDelete: node('#dogiri-clips-delete'), clipsKeep: node('#dogiri-clips-keep'), clipsMerge: node('#dogiri-clips-merge'), clipsClose: node('#dogiri-clips-close'), addClip: node('#dogiri-add-clip'), clipTimes: node('#dogiri-clip-times'), clipStart: node('#dogiri-clip-start'), clipEnd: node('#dogiri-clip-end'), captionEditor: node('#dogiri-caption-editor'), captionText: node('#dogiri-caption-text'), captionPosition: node('#dogiri-caption-position'), captionSize: node('#dogiri-caption-size'), addCaption: node('#dogiri-add-caption'), captionRemove: node('#dogiri-caption-remove'), timeline: node('#dogiri-timeline'), strip: node('#dogiri-strip-canvas'), regions: node('#dogiri-regions'), playhead: node('#dogiri-playhead'), scrubHandle: node('#dogiri-scrub-handle'), regionMenu: node('#dogiri-region-menu'), regionMenuLabel: node('#dogiri-region-menu-label'), regionMenuRm: node('#dogiri-region-menu-rm'), captionLane: node('#dogiri-caption-lane'), playClips: node('#dogiri-play-clips'), stats: node('#dogiri-stats'), quality: node('#dogiri-quality'), fitRow: node('#dogiri-fit-row'), fitTarget: node('#dogiri-fit-target'), exportOptions: node('#dogiri-export-options'), exportMenu: node('#dogiri-export-menu'), timelineSize: node('#dogiri-timeline-size'), filmstrip: node('#dogiri-filmstrip'), deleteOriginal: node('#dogiri-delete-original'), cancel: node('#dogiri-cancel'), render: node('#dogiri-render'), exportGroup: node('#dogiri-export-group'), export: node('#dogiri-export'), exportArrow: node('#dogiri-export-arrow'), exportTargetMenu: node('#dogiri-export-target-menu'), confirm: node('#dogiri-confirm'), confirmText: node('#dogiri-confirm-text'), confirmRememberRow: node('#dogiri-confirm-remember-row'), confirmRemember: node('#dogiri-confirm-remember'), confirmOk: node('#dogiri-confirm-ok'), confirmAlt: node('#dogiri-confirm-alt'), confirmCancel: node('#dogiri-confirm-cancel'), }; const { nodes } = Dogiri; Dogiri.fileSize = Dogiri.file.size; nodes.filename.textContent = `${file.name} (${$.bytesToString(Dogiri.fileSize)})`; Dogiri.setFingerprintSource(Dogiri.file); $.on(nodes.close, 'click', Dogiri.close); $.on(nodes.cancel, 'click', Dogiri.close); $.on(nodes.reset, 'click', Dogiri.onReset); $.on(nodes.save, 'click', Dogiri.onSaveClick); $.on(nodes.saveOk, 'click', Dogiri.onSaveConfirm); $.on(nodes.saveCancel, 'click', () => { if (Dogiri.nodes) { Dogiri.nodes.savePanel.hidden = true; } }); $.on(nodes.saveName, 'keydown', (e) => { if (e.key !== 'Enter') { return; } e.preventDefault(); e.stopPropagation(); Dogiri.onSaveConfirm(); }); $.on(nodes.projects, 'click', Dogiri.onProjectsClick); $.on(nodes.relink, 'change', Dogiri.onRelinkPicked); $.on(nodes.play, 'click', Dogiri.togglePlay); $.on(nodes.video, 'click', Dogiri.togglePlay); $.on(nodes.playClips, 'click', Dogiri.playClipsOnly); $.on(nodes.stripAudio, 'click', Dogiri.toggleStripAudio); $.on(nodes.captionsToggle, 'click', Dogiri.toggleCaptions); $.on(nodes.clipList, 'click', Dogiri.toggleClipPanel); $.on(nodes.clipsAll, 'change', Dogiri.onClipsAllChange); $.on(nodes.clipsDelete, 'click', () => Dogiri.onClipsBulk('delete')); $.on(nodes.clipsKeep, 'click', () => Dogiri.onClipsBulk('keep')); $.on(nodes.clipsMerge, 'click', () => Dogiri.onClipsBulk('merge')); $.on(nodes.clipsClose, 'click', Dogiri.hideClipPanel); $.on(nodes.regionMenuRm, 'click', Dogiri.removeSelectedRegion); $.on(nodes.addClip, 'click', Dogiri.onAddRegion); for (const edge of ['start', 'end']) { const input = edge === 'start' ? nodes.clipStart : nodes.clipEnd; $.on(input, 'blur', () => Dogiri.onClipTimeCommit(edge)); $.on(input, 'keydown', (e) => { if (e.key !== 'Enter') { return; } e.preventDefault(); e.stopPropagation(); Dogiri.onClipTimeCommit(edge); input.select(); }); } $.on(nodes.addCaption, 'click', Dogiri.onAddCaption); $.on(nodes.captionRemove, 'click', Dogiri.onRemoveEditedCaption); $.on(nodes.captionText, 'keydown', (e) => { if (e.key !== 'Enter') { return; } e.preventDefault(); e.stopPropagation(); Dogiri.onAddCaption(); }); $.on(nodes.render, 'click', Dogiri.onRenderClick); $.on(nodes.export, 'click', () => Dogiri.onSend(Dogiri.exportTarget)); $.on(nodes.exportArrow, 'click', () => { const n = Dogiri.nodes; if (!n || Dogiri.exportJob) { return; } n.exportTargetMenu.hidden = !n.exportTargetMenu.hidden; n.exportMenu.hidden = true; n.exportOptions.classList.remove('dogiri-pressed'); }); for (const item of Array.from(nodes.exportTargetMenu.querySelectorAll('.dogiri-menu-item'))) { $.on(item, 'click', () => { const n = Dogiri.nodes; if (!n) { return; } Dogiri.exportTarget = item.dataset.target; n.exportTargetMenu.hidden = true; Dogiri.refreshOutputButtons(); }); } $.on(nodes.exportOptions, 'click', () => { const menu = Dogiri.nodes?.exportMenu; const button = Dogiri.nodes?.exportOptions; if (!menu || !button) { return; } menu.hidden = !menu.hidden; button.classList.toggle('dogiri-pressed', !menu.hidden); if (Dogiri.nodes) { Dogiri.nodes.exportTargetMenu.hidden = true; } }); $.on(nodes.confirmOk, 'click', () => Dogiri.resolveConfirm('ok')); $.on(nodes.confirmAlt, 'click', () => Dogiri.resolveConfirm('alt')); $.on(nodes.confirmCancel, 'click', () => Dogiri.resolveConfirm(false)); $.on(nodes.quality, 'change', Dogiri.onQualityChange); nodes.fitTarget.value = String(Dogiri.maxSize ? Math.round((Dogiri.maxSize / 1048576) * 100) / 100 : 4); $.get('Dogiri Fit Target', 0, (item) => { const stored = +item['Dogiri Fit Target']; if (stored > 0 && Dogiri.nodes) { Dogiri.nodes.fitTarget.value = String(stored); Dogiri.renderStats(); } }); $.on(nodes.fitTarget, 'input', () => { const n = Dogiri.nodes; if (!n) { return; } const value = parseFloat(n.fitTarget.value); if (value > 0) { $.set('Dogiri Fit Target', value); } Dogiri.renderStats(); }); nodes.timelineSize.value = TIMELINE_HEIGHTS[Conf['Dogiri Timeline Size']] ? Conf['Dogiri Timeline Size'] : 'small'; nodes.filmstrip.checked = !!Conf['Dogiri Filmstrip']; Dogiri.applyTimelineSize(); $.on(nodes.timelineSize, 'change', () => { const n = Dogiri.nodes; if (!n) { return; } Conf['Dogiri Timeline Size'] = n.timelineSize.value; $.set('Dogiri Timeline Size', n.timelineSize.value); Dogiri.applyTimelineSize(); Dogiri.buildFilmstrip(); }); $.on(nodes.filmstrip, 'change', () => { const n = Dogiri.nodes; if (!n) { return; } Conf['Dogiri Filmstrip'] = n.filmstrip.checked; $.set('Dogiri Filmstrip', n.filmstrip.checked); Dogiri.buildFilmstrip(); }); $.on(nodes.video, 'loadedmetadata', Dogiri.onMetadata); $.on(nodes.resize, 'pointerdown', Dogiri.onResizePointerDown); $.on(nodes.resize, 'pointermove', Dogiri.onResizePointerMove); $.on(nodes.resize, 'pointerup', Dogiri.onResizePointerUp); $.on(nodes.resize, 'pointercancel', Dogiri.onResizePointerUp); window.addEventListener('resize', Dogiri.onWindowResize); $.on(nodes.el, 'pointerdown', Dogiri.onDialogPointerDown); $.on(nodes.timeline, 'pointerdown', Dogiri.onTimelinePointerDown); $.on(nodes.timeline, 'pointermove', Dogiri.onTimelinePointerMove); $.on(nodes.timeline, 'pointerup', Dogiri.onTimelinePointerUp); $.on(nodes.timeline, 'pointercancel', Dogiri.onTimelinePointerUp); $.on(nodes.captionLane, 'pointerdown', Dogiri.onLanePointerDown); $.on(nodes.captionLane, 'pointermove', Dogiri.onLanePointerMove); $.on(nodes.captionLane, 'pointerup', Dogiri.onLanePointerUp); $.on(nodes.captionLane, 'pointercancel', Dogiri.onLanePointerUp); $.on(nodes.captionOverlay, 'pointerdown', Dogiri.onOverlayPointerDown); $.on(nodes.captionOverlay, 'pointermove', Dogiri.onOverlayPointerMove); $.on(nodes.captionOverlay, 'pointerup', Dogiri.onOverlayPointerUp); $.on(nodes.captionOverlay, 'pointercancel', Dogiri.onOverlayPointerUp); d.addEventListener('keydown', Dogiri.onKeydown, true); $.on(el, 'click', (e) => { const anchor = e.target?.closest?.('a[href^="javascript:"]'); if (anchor) { e.preventDefault(); } }); Dogiri.objectURL = URL.createObjectURL(Dogiri.file); nodes.video.src = Dogiri.objectURL; $.add(d.body, el); $.addClass(doc, 'dogiri-open'); Dogiri.startLoop(); }, close() { if (!Dogiri.nodes) { return; } Dogiri.autosaveTick(); clearInterval(Dogiri.autosaveTimer); Dogiri.autosaveTimer = 0; Dogiri.exportJob?.cancel(); Dogiri.resolveConfirm(false); cancelAnimationFrame(Dogiri.rafId); d.removeEventListener('keydown', Dogiri.onKeydown, true); Dogiri.nodes.video.pause(); $.rm(Dogiri.nodes.el); $.rmClass(doc, 'dogiri-open'); URL.revokeObjectURL(Dogiri.objectURL); window.removeEventListener('resize', Dogiri.onWindowResize); clearTimeout(Dogiri.stripFillTimer); Dogiri.stripFillTimer = 0; Dogiri.stripToken++; if (Dogiri.stripSource) { Dogiri.stripSource.video.removeAttribute('src'); Dogiri.stripSource.video.load(); URL.revokeObjectURL(Dogiri.stripSource.url); Dogiri.stripSource = null; } Dogiri.stripCache = []; Dogiri.resizeDrag = null; Dogiri.fingerprintPromise = null; Dogiri.fingerprint = ''; Dogiri.pendingProject = null; Dogiri.relinkProject = null; Dogiri.lastAutosaveSignature = ''; Dogiri.currentProjectName = ''; Dogiri.sessionAcknowledged = false; Dogiri.fileSwapped = false; Dogiri.nodes = null; Dogiri.state = null; Dogiri.history = createHistory(); Dogiri.gestureSnapshot = null; Dogiri.objectURL = ''; Dogiri.fileSize = 0; Dogiri.editingCaptionId = 0; Dogiri.selectedRegionId = 0; Dogiri.regionMenuVisible = false; Dogiri.checkedClipIds = new Set(); Dogiri.dragTarget = null; Dogiri.pendingClipSeek = null; Dogiri.laneDrag = null; Dogiri.overlayDrag = null; Dogiri.pendingRange = null; Dogiri.playingClips = false; Dogiri.audioStripped = false; Dogiri.hasAudio = true; Dogiri.maxSize = 0; Dogiri.file = null; Dogiri.lastExport = null; Dogiri.onExportDone = null; Dogiri.calibration = {}; Dogiri.exportTarget = 'qr'; }, onKeydown(e) { if (e.key === 'Escape') { e.stopImmediatePropagation(); if (Dogiri.confirmResolve) { Dogiri.resolveConfirm(false); return; } const { nodes } = Dogiri; if (nodes && (e.target === nodes.clipStart || e.target === nodes.clipEnd)) { Dogiri.updateClipInputs(true); e.target.blur(); return; } if (nodes && (!nodes.savePanel.hidden || !nodes.projectsMenu.hidden || !nodes.clipPanel.hidden)) { nodes.savePanel.hidden = true; nodes.projectsMenu.hidden = true; Dogiri.hideClipPanel(); return; } Dogiri.close(); return; } const target = e.target; const tag = target?.tagName; if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA' || target?.isContentEditable) { return; } if (Dogiri.confirmResolve) { return; } const { nodes, state } = Dogiri; if (!nodes || !state) { return; } const handled = () => { e.preventDefault(); e.stopImmediatePropagation(); }; if ((e.ctrlKey || e.metaKey) && !e.altKey) { const key = e.key.toLowerCase(); if (key === 'z') { handled(); if (e.shiftKey) { Dogiri.onRedo(); } else { Dogiri.onUndo(); } } else if (key === 'y' && !e.shiftKey) { handled(); Dogiri.onRedo(); } return; } if (e.altKey) { return; } switch (e.key) { case ' ': handled(); Dogiri.togglePlay(); break; case 'ArrowLeft': handled(); Dogiri.seekBy(e.shiftKey ? -5 : -1); break; case 'ArrowRight': handled(); Dogiri.seekBy(e.shiftKey ? 5 : 1); break; case ',': handled(); nodes.video.pause(); Dogiri.seekBy(-1 / 30); break; case '.': handled(); nodes.video.pause(); Dogiri.seekBy(1 / 30); break; case 'Home': handled(); nodes.video.currentTime = 0; break; case 'End': handled(); nodes.video.currentTime = state.duration; break; } }, seekBy(delta) { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } nodes.video.currentTime = Math.min(Math.max(nodes.video.currentTime + delta, 0), state.duration); }, onUndo() { const { state } = Dogiri; if (!state || !undo(Dogiri.history, state)) { return; } Dogiri.afterHistoryRestore(); }, onRedo() { const { state } = Dogiri; if (!state || !redo(Dogiri.history, state)) { return; } Dogiri.afterHistoryRestore(); }, afterHistoryRestore() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } nodes.video.muted = state.stripAudio; Dogiri.refreshStripAudioButton(); if (Dogiri.editingCaptionId && !state.captions.some(caption => caption.id === Dogiri.editingCaptionId)) { Dogiri.stopEditingCaption(); } Dogiri.renderRegions(); Dogiri.renderCaptions(); }, askChoice(message, okLabel, opts) { const { nodes } = Dogiri; if (!nodes) { return Promise.resolve(false); } Dogiri.resolveConfirm(false); nodes.confirm.classList.toggle('dogiri-confirm-top', opts?.anchor === 'top'); nodes.confirmText.textContent = message; nodes.confirmOk.textContent = okLabel; nodes.confirmAlt.hidden = !opts?.altLabel; nodes.confirmAlt.textContent = opts?.altLabel || ''; nodes.confirmRememberRow.hidden = !opts?.remember; nodes.confirmRemember.checked = false; nodes.confirm.hidden = false; return new Promise(resolve => { Dogiri.confirmResolve = resolve; }); }, askConfirm(message, okLabel, opts) { return Dogiri.askChoice(message, okLabel, opts).then(choice => choice === 'ok'); }, resolveConfirm(choice) { const resolve = Dogiri.confirmResolve; Dogiri.confirmResolve = null; if (Dogiri.nodes) { Dogiri.nodes.confirm.hidden = true; } resolve?.(choice); }, notify(type, text, seconds) { const { nodes } = Dogiri; if (!nodes) { new Notice(type, text, seconds); return; } const toast = $.el('div', { className: `dogiri-toast ${type}`, textContent: text }); $.on(toast, 'click', () => $.rm(toast)); $.add(nodes.toasts, toast); setTimeout(() => $.rm(toast), seconds * 1000); }, onMetadata() { const { nodes } = Dogiri; if (!nodes) { return; } Dogiri.state = createState(nodes.video.duration); Dogiri.history = createHistory(); nodes.duration.textContent = formatTime(Dogiri.state.duration); const media = nodes.video; if (typeof media.mozHasAudio === 'boolean') { Dogiri.hasAudio = media.mozHasAudio; } else if (media.audioTracks && typeof media.audioTracks.length === 'number') { Dogiri.hasAudio = media.audioTracks.length > 0; } else if (Dogiri.file) { const file = Dogiri.file; VideoStripper.hasAudioTrack(file).then(hasAudio => { if (hasAudio == null || hasAudio === Dogiri.hasAudio || Dogiri.file !== file) { return; } Dogiri.hasAudio = hasAudio; Dogiri.refreshStripAudioButton(); Dogiri.renderStats(); }); } const pending = Dogiri.pendingProject; if (pending) { Dogiri.pendingProject = null; Dogiri.applyLoadedProject(pending); } Dogiri.refreshStripAudioButton(); Dogiri.renderRegions(); Dogiri.buildFilmstrip(); clearInterval(Dogiri.autosaveTimer); Dogiri.autosaveTimer = window.setInterval(() => Dogiri.autosaveTick(), 5000); if (!pending) { Dogiri.maybeOfferAutosaveRestore(); } }, stateIsPristine() { const { state } = Dogiri; return !state || (!state.captions.length && !state.stripAudio && state.regions.length === 1 && state.regions[0].start === 0 && state.regions[0].end === state.duration); }, autosaveTick() { const { nodes, state, file, fingerprint } = Dogiri; if (!nodes || !state || !file || !fingerprint || !Conf['Dogiri Autosave']) { return; } if (Dogiri.stateIsPristine()) { return; } const project = serializeProject(state, 'Autosave', Date.now(), { name: file.name, size: file.size, fingerprint }, nodes.quality.value); project.restored = Dogiri.sessionAcknowledged; const signature = JSON.stringify([project.regions, project.captions, project.stripAudio, project.quality, project.restored]); if (signature === Dogiri.lastAutosaveSignature) { return; } Dogiri.lastAutosaveSignature = signature; $.get('Dogiri Autosaves', {}, (item) => { const all = item['Dogiri Autosaves'] || {}; all[fingerprint] = project; const keys = Object.keys(all).sort((a, b) => (all[b].savedAt || 0) - (all[a].savedAt || 0)); for (const key of keys.slice(10)) { delete all[key]; } $.set('Dogiri Autosaves', all); }); }, maybeOfferAutosaveRestore() { if (!Conf['Dogiri Autosave']) { return; } const file = Dogiri.file; if (!file) { return; } Dogiri.fingerprintPromise?.then(fingerprint => { if (!fingerprint || Dogiri.file !== file) { return; } $.get('Dogiri Autosaves', {}, async (item) => { const saved = item['Dogiri Autosaves']?.[fingerprint]; if (!saved || Dogiri.file !== file || !Dogiri.nodes || Dogiri.pendingProject) { return; } if (saved.restored) { Dogiri.applyLoadedProject(saved); return; } const ok = await Dogiri.askConfirm(`You have unsaved Dōgiri edits for this video from ${new Date(saved.savedAt).toLocaleString()}. Restore them?`, 'Restore', { anchor: 'top' }); if (!ok || Dogiri.file !== file || !Dogiri.nodes) { return; } Dogiri.applyLoadedProject(saved); }); }); }, setFingerprintSource(file) { Dogiri.fingerprint = ''; const promise = Dogiri.fingerprintFile(file); Dogiri.fingerprintPromise = promise; promise.then(fingerprint => { if (Dogiri.fingerprintPromise === promise) { Dogiri.fingerprint = fingerprint; } }); }, async fingerprintFile(file) { try { const head = await file.slice(0, 1 << 20).arrayBuffer(); const digest = await crypto.subtle.digest('SHA-256', head); const hex = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); return `${file.size}-${hex}`; } catch { return ''; } }, projectBaseName() { return Dogiri.file?.name.replace(/\.[^.]*$/, '') || 'Untitled'; }, onReset() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } const before = snapshot(state); resetState(state); commitHistory(Dogiri.history, before, state); Dogiri.currentProjectName = ''; Dogiri.lastExport = null; Dogiri.lastAutosaveSignature = ''; Dogiri.fingerprintPromise?.then(fingerprint => { if (!fingerprint) { return; } $.get('Dogiri Autosaves', {}, (item) => { const all = item['Dogiri Autosaves'] || {}; if (!all[fingerprint]) { return; } delete all[fingerprint]; $.set('Dogiri Autosaves', all); }); }); Dogiri.stopEditingCaption(); nodes.video.muted = false; Dogiri.refreshStripAudioButton(); Dogiri.renderRegions(); Dogiri.renderCaptions(); }, onSaveClick() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } nodes.projectsMenu.hidden = true; nodes.exportMenu.hidden = true; nodes.exportTargetMenu.hidden = true; Dogiri.hideClipPanel(); nodes.savePanel.hidden = !nodes.savePanel.hidden; if (nodes.savePanel.hidden) { return; } if (!nodes.saveName.value.trim()) { nodes.saveName.value = Dogiri.currentProjectName || Dogiri.projectBaseName(); } nodes.saveName.focus(); nodes.saveName.select(); }, async onSaveConfirm() { const { nodes } = Dogiri; if (!nodes) { return; } const name = nodes.saveName.value.trim() || Dogiri.projectBaseName(); await Dogiri.saveProjectAs(name); if (Dogiri.nodes) { Dogiri.nodes.savePanel.hidden = true; } }, saveProjectAs(name) { const { nodes, state, file } = Dogiri; if (!nodes || !state || !file) { return Promise.resolve(); } return Dogiri.fingerprintPromise.then(fingerprint => new Promise(resolve => { const project = serializeProject(state, name, Date.now(), { name: file.name, size: file.size, fingerprint: fingerprint || '' }, nodes.quality.value); $.get('Dogiri Projects', {}, (item) => { const all = item['Dogiri Projects'] || {}; all[name] = project; $.set('Dogiri Projects', all); Dogiri.currentProjectName = name; Dogiri.sessionAcknowledged = true; Dogiri.notify('success', `Dōgiri project "${name}" saved.`, 3); resolve(); }); })); }, onProjectsClick() { const { nodes } = Dogiri; if (!nodes) { return; } nodes.savePanel.hidden = true; nodes.exportMenu.hidden = true; nodes.exportTargetMenu.hidden = true; Dogiri.hideClipPanel(); if (!nodes.projectsMenu.hidden) { nodes.projectsMenu.hidden = true; return; } $.get('Dogiri Projects', {}, (item) => { Dogiri.renderProjectsMenu(item['Dogiri Projects'] || {}); if (Dogiri.nodes) { Dogiri.nodes.projectsMenu.hidden = false; } }); }, renderProjectsMenu(all) { const menu = Dogiri.nodes?.projectsMenu; if (!menu) { return; } menu.textContent = ''; const projects = Object.values(all).sort((a, b) => b.savedAt - a.savedAt); if (!projects.length) { $.add(menu, $.el('span', { className: 'dogiri-projects-empty', textContent: 'No saved projects yet.' })); return; } for (const project of projects) { const row = $.el('div', { className: 'dogiri-project-row' }); const item = $.el('a', { className: 'dogiri-menu-item', href: 'javascript:;' }); item.title = `Video: ${project.video?.name || 'unknown'}\nSaved: ${new Date(project.savedAt).toLocaleString()}`; $.add(item, $.el('span', { textContent: project.name })); $.add(item, $.el('span', { className: 'dogiri-project-meta', textContent: `${formatTime(project.duration)}, ${project.regions?.length || 0} clip${project.regions?.length === 1 ? '' : 's'}, ${project.captions?.length || 0} caption${project.captions?.length === 1 ? '' : 's'}`, })); $.add(item, $.el('span', { className: 'dogiri-project-meta', textContent: project.video?.name || 'unknown video', })); $.on(item, 'click', () => { if (Dogiri.nodes) { Dogiri.nodes.projectsMenu.hidden = true; } Dogiri.loadProject(project); }); const rm = $.el('a', { className: 'dogiri-project-rm', href: 'javascript:;', textContent: '✕', title: 'Delete project' }); $.on(rm, 'click', (e) => { e.stopPropagation(); $.get('Dogiri Projects', {}, (item2) => { const current = item2['Dogiri Projects'] || {}; delete current[project.name]; $.set('Dogiri Projects', current); Dogiri.renderProjectsMenu(current); }); }); $.add(row, item); $.add(row, rm); $.add(menu, row); } }, async loadProject(project) { const { file, state } = Dogiri; if (!file || !state) { return; } if (!Dogiri.stateIsPristine()) { const saveName = Dogiri.currentProjectName || Dogiri.projectBaseName(); const choice = await Dogiri.askChoice(`Open "${project.name}" now? "Save & open" stores your current edits as the project "${saveName}" first.`, 'Save & open', { altLabel: 'Open without saving', anchor: 'top' }); if (!choice || Dogiri.file !== file) { return; } if (choice === 'ok') { await Dogiri.saveProjectAs(saveName); } } const fingerprint = await Dogiri.fingerprintPromise; const { nodes } = Dogiri; if (!nodes || Dogiri.file !== file) { return; } if (fingerprint && project.video?.fingerprint === fingerprint) { Dogiri.applyLoadedProject(project); return; } Dogiri.notify('info', `Locate the video file "${project.video?.name || 'unknown'}" for the project "${project.name}".`, 8); Dogiri.relinkProject = project; nodes.relink.value = ''; nodes.relink.click(); }, async onRelinkPicked() { const { nodes } = Dogiri; const project = Dogiri.relinkProject; Dogiri.relinkProject = null; const picked = nodes?.relink.files?.[0]; if (!nodes || !project || !picked) { return; } const fingerprint = await Dogiri.fingerprintFile(picked); if (!fingerprint || fingerprint !== project.video?.fingerprint) { const ok = await Dogiri.askConfirm(`"${picked.name}" does not look like the video this project was saved for (${project.video?.name || 'unknown'}). Load the project onto it anyway?`, 'Load anyway', { anchor: 'top' }); if (!ok) { return; } } Dogiri.swapFile(picked, project); }, swapFile(file, project) { const { nodes } = Dogiri; if (!nodes) { return; } Dogiri.exportJob?.cancel(); Dogiri.pendingProject = project; Dogiri.file = file; Dogiri.fileSize = file.size; Dogiri.audioStripped = false; Dogiri.hasAudio = true; Dogiri.lastExport = null; Dogiri.fileSwapped = true; nodes.deleteOriginal.checked = false; nodes.deleteOriginal.disabled = true; nodes.deleteOriginal.parentElement.title = 'The loaded project uses a different video, so sending to the QR adds a new post file and keeps the current one.'; Dogiri.setFingerprintSource(file); Dogiri.lastAutosaveSignature = ''; Dogiri.stripToken++; Dogiri.stripCache = []; if (Dogiri.stripSource) { Dogiri.stripSource.video.removeAttribute('src'); Dogiri.stripSource.video.load(); URL.revokeObjectURL(Dogiri.stripSource.url); Dogiri.stripSource = null; } URL.revokeObjectURL(Dogiri.objectURL); Dogiri.objectURL = URL.createObjectURL(file); nodes.filename.textContent = `${file.name} (${$.bytesToString(file.size)})`; nodes.video.src = Dogiri.objectURL; }, applyLoadedProject(project) { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } applyProject(state, project); if (!Dogiri.hasAudio) { state.stripAudio = false; } if (['high', 'medium', 'low', 'min', 'fit'].includes(project.quality)) { nodes.quality.value = project.quality; nodes.fitRow.hidden = project.quality !== 'fit'; } Dogiri.history = createHistory(); Dogiri.gestureSnapshot = null; Dogiri.pendingRange = null; Dogiri.lastExport = null; Dogiri.stopEditingCaption(); nodes.video.muted = state.stripAudio; if (project.name && project.name !== 'Autosave') { Dogiri.currentProjectName = project.name; } Dogiri.sessionAcknowledged = true; Dogiri.refreshStripAudioButton(); Dogiri.renderRegions(); Dogiri.renderCaptions(); Dogiri.notify('success', project.name === 'Autosave' ? 'Dōgiri restored your unsaved edits for this video.' : `Dōgiri project "${project.name}" loaded.`, 3); }, applyTimelineSize() { const { nodes } = Dogiri; if (!nodes) { return; } const height = TIMELINE_HEIGHTS[nodes.timelineSize.value] || TIMELINE_HEIGHTS.small; nodes.el.style.setProperty('--dogiri-timeline-height', `${height}px`); }, buildFilmstrip() { Dogiri.paintFilmstrip(); Dogiri.fillFilmstrip(); }, stripLayout() { const { nodes, state } = Dogiri; if (!nodes || !state?.duration) { return null; } if (!(nodes.video.videoWidth && nodes.video.videoHeight)) { return null; } const rect = nodes.strip.getBoundingClientRect(); if (!(rect.width && rect.height)) { return null; } const dpr = window.devicePixelRatio || 1; const width = Math.round(rect.width * dpr); const height = Math.round(rect.height * dpr); const thumbWidth = Math.max(8, Math.round(height * (nodes.video.videoWidth / nodes.video.videoHeight))); const count = Math.max(1, Math.ceil(width / thumbWidth)); return { count, thumbWidth, slotDuration: state.duration / count, width, height }; }, paintFilmstrip() { const { nodes } = Dogiri; if (!nodes) { return; } const layout = Dogiri.stripLayout(); if (!layout) { return; } Dogiri.stripToken++; const strip = nodes.strip; if (strip.width !== layout.width || strip.height !== layout.height) { strip.width = layout.width; strip.height = layout.height; } else { strip.getContext('2d')?.clearRect(0, 0, strip.width, strip.height); } if (!Conf['Dogiri Filmstrip']) { return; } const ctx = strip.getContext('2d'); if (!ctx) { return; } const times = Dogiri.stripCache.map(entry => (entry.height === layout.height ? entry.time : Infinity)); for (let i = 0; i < layout.count; i++) { const target = (i + 0.5) * layout.slotDuration; const index = nearestIndex(times, target); if (index < 0 || Math.abs(Dogiri.stripCache[index].time - target) > layout.slotDuration) { continue; } ctx.drawImage(Dogiri.stripCache[index].canvas, i * layout.thumbWidth, 0, layout.thumbWidth, layout.height); } }, async ensureStripVideo() { if (!Dogiri.stripSource) { const file = Dogiri.file; if (!file) { return null; } const video = $.el('video'); video.muted = true; video.preload = 'auto'; video.playsInline = true; const url = URL.createObjectURL(file); const ready = new Promise(resolve => { video.addEventListener('loadedmetadata', () => resolve(true), { once: true }); video.addEventListener('error', () => resolve(false), { once: true }); }); video.src = url; Dogiri.stripSource = { video, url, ready }; } const source = Dogiri.stripSource; return (await source.ready) ? source.video : null; }, async fillFilmstrip() { clearTimeout(Dogiri.stripFillTimer); const token = ++Dogiri.stripToken; const { nodes, state } = Dogiri; if (!nodes || !state?.duration || !Conf['Dogiri Filmstrip']) { return; } const layout = Dogiri.stripLayout(); if (!layout) { return; } const ctx = nodes.strip.getContext('2d'); if (!ctx) { return; } const video = await Dogiri.ensureStripVideo(); if (!video || token !== Dogiri.stripToken) { return; } for (let i = 0; i < layout.count; i++) { const target = (i + 0.5) * layout.slotDuration; const cached = Dogiri.stripCache.some(entry => entry.height === layout.height && Math.abs(entry.time - target) <= layout.slotDuration / 2); if (cached) { continue; } await new Promise(resolve => { if (Math.abs(video.currentTime - target) < 0.001) { resolve(); return; } video.addEventListener('seeked', () => resolve(), { once: true }); video.currentTime = target; }); if (token !== Dogiri.stripToken) { return; } const thumb = $.el('canvas'); thumb.width = layout.thumbWidth; thumb.height = layout.height; thumb.getContext('2d')?.drawImage(video, 0, 0, thumb.width, thumb.height); if (Dogiri.stripCache.length < 600) { Dogiri.stripCache.push({ time: target, height: layout.height, canvas: thumb }); } ctx.drawImage(thumb, i * layout.thumbWidth, 0, layout.thumbWidth, layout.height); } }, scheduleStripFill() { clearTimeout(Dogiri.stripFillTimer); Dogiri.stripFillTimer = window.setTimeout(() => { Dogiri.fillFilmstrip(); }, 250); }, dialogWidth() { const body = Dogiri.nodes?.resize.parentElement; return body ? body.getBoundingClientRect().width : 0; }, applyDialogWidth(width) { const el = Dogiri.nodes?.el; if (!el) { return; } const clamped = Math.min(Math.max(width, 520), Math.round(window.innerWidth * 0.96)); el.style.setProperty('--dogiri-width', `${clamped}px`); Dogiri.paintFilmstrip(); Dogiri.scheduleStripFill(); Dogiri.positionRegionMenu(); }, onResizePointerDown(e) { const { nodes } = Dogiri; if (!nodes) { return; } e.preventDefault(); Dogiri.resizeDrag = { startX: e.clientX, startWidth: Dogiri.dialogWidth() }; nodes.resize.setPointerCapture(e.pointerId); }, onResizePointerMove(e) { const { resizeDrag } = Dogiri; if (!resizeDrag) { return; } Dogiri.applyDialogWidth(resizeDrag.startWidth + (e.clientX - resizeDrag.startX) * 2); }, onResizePointerUp() { if (!Dogiri.resizeDrag) { return; } Dogiri.resizeDrag = null; const width = Math.round(Dogiri.dialogWidth()); if (width) { $.set('Dogiri Size', { width }); } Dogiri.fillFilmstrip(); }, onWindowResize() { Dogiri.paintFilmstrip(); Dogiri.scheduleStripFill(); Dogiri.positionRegionMenu(); }, refreshStripAudioButton() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } nodes.stripAudio.classList.remove('dogiri-pressed', 'disabled'); nodes.stripAudio.title = ''; if (Dogiri.audioStripped && Dogiri.hasAudio) { state.stripAudio = true; nodes.video.muted = true; nodes.stripAudio.classList.add('dogiri-pressed'); nodes.stripAudio.title = 'Audio was removed automatically for this board; unpress to keep it'; } else if (Dogiri.audioStripped) { state.stripAudio = true; nodes.stripAudio.classList.add('dogiri-pressed', 'disabled'); nodes.stripAudio.title = 'Audio was removed and no original is available to restore'; } else if (!Dogiri.hasAudio) { state.stripAudio = false; nodes.stripAudio.classList.add('disabled'); nodes.stripAudio.title = 'Video has no audio track'; } else { nodes.stripAudio.classList.toggle('dogiri-pressed', state.stripAudio); } }, togglePlay() { const video = Dogiri.nodes?.video; if (!video) { return; } Dogiri.playingClips = false; if (video.paused) { video.play().catch(() => { }); } else { video.pause(); } }, playClipsOnly() { const { nodes, state } = Dogiri; if (!nodes || !state?.regions.length) { return; } const { video } = nodes; if (Dogiri.playingClips && !video.paused) { video.pause(); return; } Dogiri.playingClips = true; if (!inRegion(state, video.currentTime)) { video.currentTime = nextRegionStart(state, video.currentTime) ?? state.regions[0].start; } video.play().catch(() => { }); }, startLoop() { const step = () => { Dogiri.renderFrame(); Dogiri.rafId = requestAnimationFrame(step); }; Dogiri.rafId = requestAnimationFrame(step); }, fraction(time) { const duration = Dogiri.state?.duration; return duration ? Math.min(Math.max(time / duration, 0), 1) : 0; }, renderFrame() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } const { video } = nodes; if (Dogiri.playingClips && !video.paused && !video.seeking && !inRegion(state, video.currentTime)) { const next = nextRegionStart(state, video.currentTime); if (next != null) { video.currentTime = next; } else { video.pause(); Dogiri.playingClips = false; } } nodes.time.textContent = formatTime(video.currentTime, true); nodes.play.textContent = video.paused ? '▶' : '⏸'; nodes.playClips.classList.toggle('dogiri-pressed', Dogiri.playingClips); nodes.playClips.textContent = Dogiri.playingClips && !video.paused ? '⏸ Pause clips' : '▶ Play clips'; const playheadPct = `${Dogiri.fraction(video.currentTime) * 100}%`; nodes.playhead.style.left = playheadPct; nodes.scrubHandle.style.left = playheadPct; Dogiri.renderCaptionOverlay(); }, measureText(font, text) { if (!Dogiri.measureCtx) { Dogiri.measureCtx = $.el('canvas').getContext('2d'); if (!Dogiri.measureCtx) { return 0; } } Dogiri.measureCtx.font = font; return Dogiri.measureCtx.measureText(text).width; }, renderCaptionOverlay() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } const container = nodes.captionOverlay; const active = captionsAt(state, nodes.video.currentTime); const activeIds = new Set(active.map(caption => caption.id)); const children = new Map(); for (const child of Array.from(container.children)) { const id = +child.dataset.id; if (activeIds.has(id)) { children.set(id, child); } else { $.rm(child); } } container.hidden = !active.length; if (!active.length) { return; } const boxRect = nodes.previewBox.getBoundingClientRect(); const videoRect = nodes.video.getBoundingClientRect(); if (!(videoRect.width && videoRect.height)) { return; } for (const caption of active) { let child = children.get(caption.id); if (!child) { child = $.el('div', { className: 'dogiri-preview-caption' }); child.dataset.id = String(caption.id); $.add(container, child); } const px = videoRect.height * SIZE_FRACTIONS[caption.size]; const font = `bold ${px}px sans-serif`; const maxWidth = videoRect.width * CAPTION_MAX_WIDTH_FRACTION; const key = JSON.stringify([ caption.text, caption.y, caption.size, Math.round(videoRect.width), Math.round(videoRect.height), Math.round(videoRect.left - boxRect.left), Math.round(videoRect.top - boxRect.top), ]); if (child.dataset.key === key) { continue; } child.dataset.key = key; const lines = wrapCaptionLines(text => Dogiri.measureText(font, text), caption.text, maxWidth); child.textContent = ''; lines.forEach((line, index) => { if (index) { $.add(child, $.el('br')); } $.add(child, $.tn(line)); }); const lineHeight = px * CAPTION_LINE_HEIGHT; const blockHeightPct = ((lines.length * lineHeight) / videoRect.height) * 100; const topPct = captionBlockTop(caption.y, blockHeightPct); child.style.fontSize = `${px}px`; child.style.lineHeight = `${lineHeight}px`; child.style.left = `${videoRect.left - boxRect.left + videoRect.width * ((1 - CAPTION_MAX_WIDTH_FRACTION) / 2)}px`; child.style.width = `${maxWidth}px`; child.style.top = `${videoRect.top - boxRect.top + (topPct / 100) * videoRect.height}px`; } }, selectedRegion() { const { state } = Dogiri; if (!state) { return null; } return state.regions.find(region => region.id === Dogiri.selectedRegionId) || state.regions[0] || null; }, updateClipInputs(force = false) { const { nodes } = Dogiri; if (!nodes) { return; } const region = Dogiri.selectedRegion(); nodes.clipTimes.hidden = !region; if (!region) { return; } if (force || d.activeElement !== nodes.clipStart) { nodes.clipStart.value = formatTime(region.start, true); } if (force || d.activeElement !== nodes.clipEnd) { nodes.clipEnd.value = formatTime(region.end, true); } }, onClipTimeCommit(edge) { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } const region = Dogiri.selectedRegion(); if (!region) { return; } const input = edge === 'start' ? nodes.clipStart : nodes.clipEnd; const time = parseTime(input.value); if (time == null) { Dogiri.updateClipInputs(true); return; } const before = snapshot(state); moveRegionEdge(state, region.id, edge, time); commitHistory(Dogiri.history, before, state); Dogiri.renderRegions(); Dogiri.updateClipInputs(true); }, renderRegions() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } const selected = Dogiri.selectedRegion(); nodes.regions.textContent = ''; state.regions.forEach((region, index) => { const el = $.el('span', { className: `dogiri-region${index % 2 ? ' alt' : ''}${region.id === selected?.id ? ' selected' : ''}` }); el.dataset.id = String(region.id); el.title = `${formatTime(region.start)}-${formatTime(region.end)}`; const startPct = Dogiri.fraction(region.start) * 100; const endPct = Dogiri.fraction(region.end) * 100; el.style.left = `${startPct}%`; el.style.width = `${endPct - startPct}%`; const startHandle = $.el('span', { className: 'dogiri-region-handle' }); startHandle.dataset.edge = 'start'; const endHandle = $.el('span', { className: 'dogiri-region-handle' }); endHandle.dataset.edge = 'end'; $.add(el, startHandle); $.add(el, endHandle); $.add(nodes.regions, el); }); Dogiri.positionRegionMenu(); Dogiri.updateClipInputs(); Dogiri.renderStats(); Dogiri.renderClipList(); }, positionRegionMenu() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } const selected = state.duration && Dogiri.regionMenuVisible ? Dogiri.selectedRegion() : null; if (!selected) { nodes.regionMenu.hidden = true; return; } nodes.regionMenuLabel.textContent = `Clip ${state.regions.findIndex(region => region.id === selected.id) + 1}`; nodes.regionMenu.hidden = false; const center = nodes.timeline.offsetWidth * Dogiri.fraction((selected.start + selected.end) / 2); const half = nodes.regionMenu.offsetWidth / 2; const left = Math.min(Math.max(center, half), nodes.timeline.offsetWidth - half); nodes.regionMenu.style.left = `${Math.round(left)}px`; }, removeSelectedRegion() { const { state } = Dogiri; const selected = Dogiri.selectedRegion(); if (!state || !selected) { return; } const before = snapshot(state); removeRegion(state, selected.id); commitHistory(Dogiri.history, before, state); Dogiri.regionMenuVisible = false; Dogiri.renderRegions(); }, onQualityChange() { const { nodes } = Dogiri; if (!nodes) { return; } nodes.fitRow.hidden = nodes.quality.value !== 'fit'; Dogiri.renderStats(); }, fitTargetBytes() { const value = parseFloat(Dogiri.nodes?.fitTarget.value || ''); if (value > 0) { return Math.round(value * 1048576); } return Dogiri.maxSize || 4 * 1048576; }, calibrationFactor(quality) { return Dogiri.calibration[quality] ?? Dogiri.persistentCalibration[quality] ?? 1; }, currentFitSettings() { const { nodes, state } = Dogiri; if (!nodes || !state || nodes.quality.value !== 'fit' || !nodes.video.videoWidth) { return null; } return fitSettings(nodes.video.videoWidth, nodes.video.videoHeight, totalClipped(state), Dogiri.fitTargetBytes(), !state.stripAudio && Dogiri.hasAudio, Dogiri.calibrationFactor('fit')); }, estimatedExportBytes() { const { nodes, state } = Dogiri; if (!nodes || !state) { return 0; } const total = totalClipped(state); if (!(nodes.video.videoWidth && nodes.video.videoHeight)) { return state.duration > 0 ? Math.round(Dogiri.fileSize * (total / state.duration)) : 0; } const quality = nodes.quality.value; const { width, height, videoBps } = Dogiri.currentFitSettings() || exportSettings(nodes.video.videoWidth, nodes.video.videoHeight, quality); const audioBps = (!state.stripAudio && Dogiri.hasAudio) ? AUDIO_BPS : 0; let effectiveVideoBps = videoBps; if (state.duration > 0 && Dogiri.fileSize > 0) { const sourceVideoBps = Math.max(0, (Dogiri.fileSize * 8) / state.duration - (Dogiri.hasAudio ? AUDIO_BPS : 0)); const pixelRatio = (width * height) / (nodes.video.videoWidth * nodes.video.videoHeight); const complexityBps = sourceVideoBps * pixelRatio * 1.35; effectiveVideoBps = Math.min(videoBps, Math.max(300000, complexityBps)); } return Math.round(((effectiveVideoBps + audioBps) / 8) * total * Dogiri.calibrationFactor(quality)); }, currentSignature() { const { nodes, state } = Dogiri; if (!nodes || !state) { return ''; } return exportSignature({ regions: state.regions, captions: state.captions, keepAudio: !state.stripAudio && Dogiri.hasAudio, quality: nodes.quality.value, fitTargetBytes: Dogiri.fitTargetBytes(), }); }, refreshOutputButtons() { const { nodes } = Dogiri; if (!nodes || Dogiri.exportJob) { return; } const rendered = !!Dogiri.lastExport && Dogiri.lastExport.signature === Dogiri.currentSignature(); nodes.render.hidden = rendered; nodes.render.textContent = 'Render'; nodes.render.classList.remove('dogiri-pressed'); nodes.exportGroup.hidden = !rendered; nodes.export.textContent = Dogiri.exportTarget === 'qr' ? 'Send → QR' : 'Save → file'; if (!rendered) { nodes.exportTargetMenu.hidden = true; } }, renderStats() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } Dogiri.refreshOutputButtons(); if (!state.regions.length) { nodes.stats.textContent = 'No clips'; return; } const total = totalClipped(state); const count = state.regions.length; const rendered = Dogiri.lastExport && Dogiri.lastExport.signature === Dogiri.currentSignature() ? Dogiri.lastExport.file : null; const size = rendered ? $.bytesToString(rendered.size) : `~${$.bytesToString(Dogiri.estimatedExportBytes())}`; nodes.stats.title = rendered ? 'The actual size of the rendered file.' : 'The size is an estimate; the rendered file may come in under or over it. Estimates improve as renders complete.'; nodes.stats.textContent = `${count} clip${count === 1 ? '' : 's'}, ${formatTime(total)} of ${formatTime(state.duration)} (${size})`; }, timeAtPointer(e, el) { const { state } = Dogiri; if (!state) { return 0; } const rect = el.getBoundingClientRect(); const fraction = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1); return fraction * state.duration; }, onDialogPointerDown(e) { if (!Dogiri.regionMenuVisible) { return; } const target = e.target; if (target.closest?.('.dogiri-region, #dogiri-region-menu, .dogiri-clip-row')) { return; } Dogiri.regionMenuVisible = false; Dogiri.positionRegionMenu(); }, onTimelinePointerDown(e) { const { nodes, state } = Dogiri; if (!nodes || !state?.duration) { return; } const target = e.target; if (target.closest?.('#dogiri-region-menu')) { return; } const handle = target.closest?.('.dogiri-region-handle'); const regionEl = target.closest?.('.dogiri-region'); if (target.closest?.('#dogiri-scrub-handle')) { Dogiri.dragTarget = { kind: 'seek' }; } else if (handle && regionEl) { const id = +regionEl.dataset.id; Dogiri.selectedRegionId = id; Dogiri.regionMenuVisible = true; Dogiri.dragTarget = { kind: 'edge', id, edge: handle.dataset.edge, }; } else if (regionEl) { const id = +regionEl.dataset.id; const region = state.regions.find(r => r.id === id); if (!region) { return; } Dogiri.selectedRegionId = id; Dogiri.regionMenuVisible = true; Dogiri.dragTarget = { kind: 'move', id, offset: Dogiri.timeAtPointer(e, nodes.timeline) - region.start, }; } else { Dogiri.dragTarget = { kind: 'seek' }; } if (Dogiri.dragTarget.kind !== 'seek') { Dogiri.gestureSnapshot = snapshot(state); } nodes.timeline.setPointerCapture(e.pointerId); Dogiri.onTimelinePointerMove(e); }, onTimelinePointerMove(e) { const { nodes, state, dragTarget } = Dogiri; if (!nodes || !state || !dragTarget) { return; } const time = Dogiri.timeAtPointer(e, nodes.timeline); if (dragTarget.kind === 'edge') { moveRegionEdge(state, dragTarget.id, dragTarget.edge, time); Dogiri.renderRegions(); } else if (dragTarget.kind === 'move') { moveRegionFree(state, dragTarget.id, time - dragTarget.offset); Dogiri.renderRegions(); } else if (Dogiri.playingClips) { if (inRegion(state, time)) { nodes.video.currentTime = time; Dogiri.pendingClipSeek = null; } else { Dogiri.pendingClipSeek = time; } } else { nodes.video.currentTime = time; } }, onTimelinePointerUp() { const { nodes, state, dragTarget } = Dogiri; if (dragTarget?.kind === 'seek' && Dogiri.playingClips && Dogiri.pendingClipSeek != null && nodes && state?.regions.length) { nodes.video.currentTime = nextRegionStart(state, Dogiri.pendingClipSeek) ?? state.regions[0].start; } if (dragTarget?.kind === 'move' && state) { const original = Dogiri.gestureSnapshot?.regions.find(region => region.id === dragTarget.id); const current = state.regions.find(region => region.id === dragTarget.id); if (current) { settleRegion(state, dragTarget.id, original || { start: current.start, end: current.end }); Dogiri.renderRegions(); } } Dogiri.pendingClipSeek = null; Dogiri.dragTarget = null; Dogiri.commitGesture(); }, onAddRegion() { const { state } = Dogiri; if (!state) { return; } const before = snapshot(state); if (!addRegion(state)) { Dogiri.notify('info', 'No room for another clip.', 3); return; } commitHistory(Dogiri.history, before, state); Dogiri.renderRegions(); }, toggleClipPanel() { const { nodes } = Dogiri; if (!nodes) { return; } if (!nodes.clipPanel.hidden) { Dogiri.hideClipPanel(); return; } nodes.savePanel.hidden = true; nodes.projectsMenu.hidden = true; nodes.clipPanel.hidden = false; nodes.clipList.classList.add('dogiri-pressed'); Dogiri.renderClipList(); }, hideClipPanel() { const { nodes } = Dogiri; if (!nodes) { return; } nodes.clipPanel.hidden = true; nodes.clipList.classList.remove('dogiri-pressed'); }, renderClipList() { const { nodes, state } = Dogiri; if (!nodes || !state || nodes.clipPanel.hidden) { return; } const live = new Set(state.regions.map(region => region.id)); for (const id of Dogiri.checkedClipIds) { if (!live.has(id)) { Dogiri.checkedClipIds.delete(id); } } const selected = Dogiri.selectedRegion(); nodes.clipRows.textContent = ''; if (!state.regions.length) { $.add(nodes.clipRows, $.el('span', { className: 'dogiri-clips-empty', textContent: 'No clips.' })); } state.regions.forEach((region, index) => { const row = $.el('div', { className: `dogiri-clip-row${region.id === selected?.id ? ' selected' : ''}` }); const check = $.el('input', { type: 'checkbox' }); check.checked = Dogiri.checkedClipIds.has(region.id); $.on(check, 'click', (e) => e.stopPropagation()); $.on(check, 'change', () => { if (check.checked) { Dogiri.checkedClipIds.add(region.id); } else { Dogiri.checkedClipIds.delete(region.id); } Dogiri.refreshClipActions(); }); $.add(row, check); $.add(row, $.el('span', { className: 'dogiri-clip-label', textContent: `Clip ${index + 1}` })); $.add(row, $.el('span', { className: 'dogiri-clip-meta', textContent: `${formatTime(region.start, true)}-${formatTime(region.end, true)} (${formatTime(region.end - region.start, true)})`, })); $.on(row, 'click', () => { Dogiri.selectedRegionId = region.id; Dogiri.regionMenuVisible = true; if (Dogiri.nodes && Dogiri.state) { Dogiri.nodes.video.currentTime = region.start; } Dogiri.renderRegions(); }); $.add(nodes.clipRows, row); }); Dogiri.refreshClipActions(); }, refreshClipActions() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } const checked = Dogiri.checkedClipIds.size; const total = state.regions.length; nodes.clipsCount.textContent = checked ? `${checked} of ${total} selected` : `${total} clip${total === 1 ? '' : 's'}`; nodes.clipsAll.checked = total > 0 && checked === total; nodes.clipsAll.indeterminate = checked > 0 && checked < total; nodes.clipsDelete.classList.toggle('disabled', !checked); nodes.clipsKeep.classList.toggle('disabled', !checked || checked === total); nodes.clipsMerge.classList.toggle('disabled', checked < 2); }, onClipsAllChange() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } Dogiri.checkedClipIds = nodes.clipsAll.checked ? new Set(state.regions.map(region => region.id)) : new Set(); Dogiri.renderClipList(); }, onClipsBulk(action) { const { state } = Dogiri; if (!state) { return; } const ids = [...Dogiri.checkedClipIds]; const total = state.regions.length; if (!ids.length || (action === 'merge' && ids.length < 2) || (action === 'keep' && ids.length === total)) { return; } const before = snapshot(state); if (action === 'merge') { const merged = mergeRegions(state, ids); if (!merged) { return; } Dogiri.checkedClipIds = new Set([merged.id]); Dogiri.selectedRegionId = merged.id; Dogiri.notify('success', `Merged ${ids.length} clips into one.`, 3); } else { const removed = action === 'delete' ? removeRegions(state, ids) : keepRegions(state, ids); Dogiri.notify('success', `Removed ${removed} clip${removed === 1 ? '' : 's'}.`, 3); } commitHistory(Dogiri.history, before, state); Dogiri.renderRegions(); }, toggleCaptions() { const { nodes } = Dogiri; if (!nodes) { return; } const showing = nodes.captionEditor.hidden; nodes.captionEditor.hidden = !showing; nodes.captionLane.hidden = !showing; nodes.captionsToggle.classList.toggle('dogiri-pressed', showing); if (!showing) { Dogiri.stopEditingCaption(); Dogiri.pendingRange = null; Dogiri.renderCaptions(); } }, onLanePointerDown(e) { const { nodes, state } = Dogiri; if (!nodes || !state?.duration) { return; } const target = e.target; if (target.closest?.('.dogiri-caption-rm')) { return; } const time = Dogiri.timeAtPointer(e, nodes.captionLane); const handle = target.closest?.('.dogiri-caption-handle'); const block = target.closest?.('.dogiri-caption-block'); if (handle && block) { Dogiri.gestureSnapshot = snapshot(state); Dogiri.laneDrag = { kind: 'cap-edge', id: +block.dataset.id, edge: handle.dataset.edge }; } else if (block) { const id = +block.dataset.id; const caption = state.captions.find(c => c.id === id); if (!caption) { return; } Dogiri.gestureSnapshot = snapshot(state); Dogiri.laneDrag = { kind: 'cap-move', id, offset: time - caption.start, downX: e.clientX, moved: false }; } else { Dogiri.laneDrag = { kind: 'range', anchor: time }; Dogiri.pendingRange = null; Dogiri.renderCaptions(); } nodes.captionLane.setPointerCapture(e.pointerId); }, onLanePointerMove(e) { const { nodes, state, laneDrag } = Dogiri; if (!nodes || !state || !laneDrag) { return; } const time = Dogiri.timeAtPointer(e, nodes.captionLane); if (laneDrag.kind === 'range') { Dogiri.pendingRange = { start: Math.min(laneDrag.anchor, time), end: Math.max(laneDrag.anchor, time), }; Dogiri.renderCaptions(); } else if (laneDrag.kind === 'cap-edge') { moveCaptionEdge(state, laneDrag.id, laneDrag.edge, time); Dogiri.renderCaptions(); } else { if (!laneDrag.moved && Math.abs(e.clientX - laneDrag.downX) <= 3) { return; } laneDrag.moved = true; moveCaption(state, laneDrag.id, time - laneDrag.offset); Dogiri.renderCaptions(); } }, onLanePointerUp() { const { state, laneDrag, pendingRange } = Dogiri; Dogiri.laneDrag = null; Dogiri.commitGesture(); if (laneDrag?.kind === 'cap-move' && !laneDrag.moved && state) { const caption = state.captions.find(c => c.id === laneDrag.id); if (caption) { Dogiri.startEditingCaption(caption); } return; } if (laneDrag?.kind !== 'range') { return; } if (pendingRange && pendingRange.end - pendingRange.start < 0.15) { Dogiri.pendingRange = null; Dogiri.renderCaptions(); return; } if (pendingRange) { Dogiri.nodes?.captionText.focus(); } }, yAtPointer(e) { const video = Dogiri.nodes?.video; if (!video) { return 0; } const rect = video.getBoundingClientRect(); return rect.height ? ((e.clientY - rect.top) / rect.height) * 100 : 0; }, onOverlayPointerDown(e) { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } const child = e.target?.closest?.('.dogiri-preview-caption'); if (!child) { return; } const caption = state.captions.find(existing => existing.id === +child.dataset.id); if (!caption) { return; } e.stopPropagation(); Dogiri.gestureSnapshot = snapshot(state); Dogiri.overlayDrag = { id: caption.id, grabOffset: Dogiri.yAtPointer(e) - caption.y }; nodes.captionOverlay.setPointerCapture(e.pointerId); }, onOverlayPointerMove(e) { const { state, overlayDrag } = Dogiri; if (!state || !overlayDrag) { return; } setCaptionY(state, overlayDrag.id, Dogiri.yAtPointer(e) - overlayDrag.grabOffset); }, onOverlayPointerUp() { Dogiri.overlayDrag = null; Dogiri.commitGesture(); Dogiri.refreshOutputButtons(); }, commitGesture() { const { state, gestureSnapshot } = Dogiri; Dogiri.gestureSnapshot = null; if (!state || !gestureSnapshot) { return; } commitHistory(Dogiri.history, gestureSnapshot, state); }, autoCaptionRange() { const { nodes, state } = Dogiri; if (!nodes || !state?.duration) { return { start: 0, end: 0 }; } const length = Math.min(3, state.duration); const start = Math.min(nodes.video.currentTime, Math.max(0, state.duration - length)); return { start, end: Math.min(state.duration, start + length) }; }, onAddCaption() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } const text = nodes.captionText.value; const position = nodes.captionPosition.value; const size = nodes.captionSize.value; const before = snapshot(state); if (Dogiri.editingCaptionId) { const y = position === Dogiri.editingInitialPosition ? undefined : POSITION_PRESETS[position]; if (!updateCaption(state, Dogiri.editingCaptionId, text, size, Dogiri.pendingRange || undefined, y)) { return; } Dogiri.pendingRange = null; Dogiri.stopEditingCaption(); } else { const range = Dogiri.pendingRange || Dogiri.autoCaptionRange(); const y = freeCaptionY(state, range.start, range.end, POSITION_PRESETS[position]); if (!addCaption(state, range.start, range.end, text, y, size)) { return; } Dogiri.pendingRange = null; nodes.captionText.value = ''; } commitHistory(Dogiri.history, before, state); Dogiri.renderCaptions(); }, onRemoveEditedCaption() { const { state } = Dogiri; if (!state || !Dogiri.editingCaptionId) { return; } const before = snapshot(state); removeCaption(state, Dogiri.editingCaptionId); commitHistory(Dogiri.history, before, state); Dogiri.stopEditingCaption(); Dogiri.renderCaptions(); }, startEditingCaption(caption) { const { nodes } = Dogiri; if (!nodes) { return; } if (Dogiri.pendingRange) { Dogiri.pendingRange = null; Dogiri.renderCaptions(); } Dogiri.editingCaptionId = caption.id; Dogiri.editingInitialPosition = caption.y < 50 ? 'top' : 'bottom'; nodes.captionText.value = caption.text; nodes.captionPosition.value = Dogiri.editingInitialPosition; nodes.captionSize.value = caption.size; nodes.addCaption.textContent = 'Update caption'; nodes.captionRemove.hidden = false; Dogiri.refreshEditingHighlight(); }, stopEditingCaption() { const { nodes } = Dogiri; if (!nodes) { return; } Dogiri.editingCaptionId = 0; nodes.captionText.value = ''; nodes.addCaption.textContent = ADD_CAPTION_LABEL; nodes.captionRemove.hidden = true; Dogiri.refreshEditingHighlight(); }, refreshEditingHighlight() { const lane = Dogiri.nodes?.captionLane; if (!lane) { return; } for (const block of Array.from(lane.querySelectorAll('.dogiri-caption-block'))) { block.classList.toggle('editing', +block.dataset.id === Dogiri.editingCaptionId); } }, renderCaptions() { const { nodes, state } = Dogiri; if (!nodes || !state) { return; } nodes.captionLane.textContent = ''; const { levels, rows } = captionLaneLevels(state); nodes.captionLane.style.height = `${rows * 20 + 2}px`; for (const caption of state.captions) { const block = $.el('span', { className: `dogiri-caption-block${caption.id === Dogiri.editingCaptionId ? ' editing' : ''}`, title: `${formatTime(caption.start)}-${formatTime(caption.end)} ${caption.text}`, }); block.dataset.id = String(caption.id); block.dataset.hue = String(caption.id % 5); block.style.top = `${2 + (levels.get(caption.id) || 0) * 20}px`; block.textContent = caption.text; const startPct = Dogiri.fraction(caption.start) * 100; const endPct = Dogiri.fraction(caption.end) * 100; block.style.left = `${startPct}%`; block.style.width = `${Math.max(endPct - startPct, 1)}%`; const startHandle = $.el('span', { className: 'dogiri-caption-handle' }); startHandle.dataset.edge = 'start'; const endHandle = $.el('span', { className: 'dogiri-caption-handle' }); endHandle.dataset.edge = 'end'; const rm = $.el('a', { className: 'dogiri-caption-rm', textContent: '✕', title: 'Remove caption' }); rm.href = 'javascript:;'; $.on(rm, 'pointerdown', (e) => e.stopPropagation()); $.on(rm, 'click', (e) => { e.stopPropagation(); const before = snapshot(state); removeCaption(state, caption.id); commitHistory(Dogiri.history, before, state); if (Dogiri.editingCaptionId === caption.id) { Dogiri.stopEditingCaption(); } Dogiri.renderCaptions(); }); $.add(block, startHandle); $.add(block, endHandle); $.add(block, rm); $.add(nodes.captionLane, block); } if (Dogiri.pendingRange) { const ghost = $.el('span', { className: 'dogiri-caption-ghost' }); const startPct = Dogiri.fraction(Dogiri.pendingRange.start) * 100; const endPct = Dogiri.fraction(Dogiri.pendingRange.end) * 100; ghost.style.left = `${startPct}%`; ghost.style.width = `${Math.max(endPct - startPct, 0.5)}%`; $.add(nodes.captionLane, ghost); } Dogiri.refreshOutputButtons(); }, toggleStripAudio() { const { nodes, state } = Dogiri; if (!nodes || !state || !Dogiri.hasAudio) { return; } const before = snapshot(state); state.stripAudio = !state.stripAudio; commitHistory(Dogiri.history, before, state); nodes.video.muted = state.stripAudio; nodes.stripAudio.classList.toggle('dogiri-pressed', state.stripAudio); Dogiri.renderStats(); }, onRenderClick() { if (Dogiri.exportJob) { Dogiri.exportJob.cancel(); return; } if (Dogiri.confirmResolve) { return; } const { nodes, state, file } = Dogiri; if (!nodes || !state || !file) { return; } if (!state.regions.length) { Dogiri.notify('warning', 'No clips to render.', 4); return; } const fit = Dogiri.currentFitSettings(); if (fit && !fit.reachable && Conf['Dogiri Oversize Render Warning']) { Dogiri.askConfirm(`Even at minimum quality the render is unlikely to fit under ${$.bytesToString(Dogiri.fitTargetBytes())}. Shorter clips${(!state.stripAudio && Dogiri.hasAudio) ? ' or stripping audio' : ''} would shrink it.`, 'Render anyway', { remember: true }).then(ok => { if (!ok) { return; } if (Dogiri.nodes?.confirmRemember.checked) { Conf['Dogiri Oversize Render Warning'] = false; $.set('Dogiri Oversize Render Warning', false); } Dogiri.startRender(); }); return; } const estimate = Dogiri.estimatedExportBytes(); if (Dogiri.maxSize && estimate > Dogiri.maxSize && Conf['Dogiri Oversize Render Warning']) { Dogiri.askConfirm(`Estimated size ${$.bytesToString(estimate)} is over the board limit of ${$.bytesToString(Dogiri.maxSize)}. This is a rough estimate and the rendered file may come in under or over it. Lower quality or shorter clips would shrink it.`, 'Render anyway', { remember: true }).then(ok => { if (!ok) { return; } if (Dogiri.nodes?.confirmRemember.checked) { Conf['Dogiri Oversize Render Warning'] = false; $.set('Dogiri Oversize Render Warning', false); } Dogiri.startRender(); }); return; } Dogiri.startRender(); }, startRender() { const { nodes, state, file } = Dogiri; if (!nodes || !state || !file || Dogiri.exportJob) { return; } const qualityAtStart = nodes.quality.value; const estimateAtStart = Dogiri.estimatedExportBytes(); const signatureAtStart = Dogiri.currentSignature(); nodes.video.pause(); Dogiri.playingClips = false; const job = exportClips({ file, regions: state.regions.map(region => ({ start: region.start, end: region.end })), captions: state.captions.map(caption => ({ start: caption.start, end: caption.end, text: caption.text, y: caption.y, size: caption.size, })), keepAudio: !state.stripAudio && Dogiri.hasAudio, quality: qualityAtStart, fitTargetBytes: Dogiri.fitTargetBytes(), fitCalibration: Dogiri.calibrationFactor(qualityAtStart), onProgress: fraction => { const el = Dogiri.nodes?.render; if (el) { el.textContent = `Cancel render (${Math.round(fraction * 100)}%)`; } }, }); Dogiri.exportJob = job; nodes.render.textContent = 'Cancel render (0%)'; nodes.render.classList.add('dogiri-pressed'); job.promise.then(async ({ blob, durationOk }) => { const base = file.name.replace(/\.[^.]*$/, '') || 'dogiri'; const exported = new File([blob], `${base}-dogiri.webm`, { type: 'video/webm' }); if (estimateAtStart > 0) { const factorAtStart = Dogiri.calibrationFactor(qualityAtStart); const absolute = Math.min(4, Math.max(0.25, (exported.size / estimateAtStart) * factorAtStart)); Dogiri.calibration[qualityAtStart] = absolute; const prior = Dogiri.persistentCalibration[qualityAtStart]; Dogiri.persistentCalibration[qualityAtStart] = prior ? (prior + absolute) / 2 : absolute; $.set('Dogiri Calibration', Dogiri.persistentCalibration); } Dogiri.exportJob = null; Dogiri.lastExport = { file: exported, signature: signatureAtStart }; Dogiri.renderStats(); Dogiri.notify('success', `Dōgiri rendered ${exported.name} (${$.bytesToString(exported.size)}).`, 5); if (!durationOk) { Dogiri.notify('warning', 'The rendered file reports no duration; the QR may reject it.', 6); } }).catch(error => { if (error?.message === EXPORT_CANCELLED) { Dogiri.notify('info', 'Dōgiri render canceled.', 3); } else { console.error(error); Dogiri.notify('warning', `Dōgiri render failed: ${error?.message || error}`, 6); } }).finally(() => { Dogiri.exportJob = null; Dogiri.refreshOutputButtons(); }); }, onSend(target) { if (Dogiri.exportJob || Dogiri.confirmResolve) { return; } const rendered = Dogiri.lastExport; if (!rendered || rendered.signature !== Dogiri.currentSignature()) { Dogiri.refreshOutputButtons(); return; } Dogiri.deliverExport(rendered.file, target); }, async deliverExport(exported, target) { if (target === 'file') { const url = URL.createObjectURL(exported); const link = $.el('a', { href: url, download: exported.name }); $.add(d.body, link); link.click(); $.rm(link); setTimeout(() => URL.revokeObjectURL(url), 60000); Dogiri.notify('success', `Dōgiri saved ${exported.name} (${$.bytesToString(exported.size)}) to file.`, 5); return; } if (Dogiri.maxSize && exported.size > Dogiri.maxSize) { const attach = await Dogiri.askConfirm(`The rendered file is ${$.bytesToString(exported.size)}, over the board limit of ${$.bytesToString(Dogiri.maxSize)}.`, 'Attach anyway'); if (!attach) { return; } } const deliver = Dogiri.onExportDone; const keepOriginal = Dogiri.fileSwapped || !(Dogiri.nodes?.deleteOriginal.checked ?? true); Dogiri.close(); deliver?.(exported, keepOriginal); }, }; var QR = { postingIsEnabled: false, captcha: undefined, min_width: 0, min_height: 0, max_width: 0, max_height: 0, max_size: 0, max_size_video: 0, max_comment: 0, max_name: 100, max_email: 100, max_sub: 100, max_width_video: 0, max_height_video: 0, max_duration_video: 0, forcedAnon: false, spoiler: false, link: undefined, post: undefined, posts: undefined, nodes: undefined, shortcut: undefined, hasFocus: false, pendingFiles: [], dropTargetPost: undefined, isDroppingFiles: false, isProcessingPendingFiles: false, audioStrippedCount: 0, audioStripNotice: null, fileBatchSize: 3, heavyBatchFileCount: 8, heavyBatchSize: 64 * 1024 * 1024, previewPost: null, previewFloat: null, previewInline: false, commentPreviewModeInitialized: false, commentPreviewDefaultModeApplied: '', commentPreviewRestoreDetachedFloat: false, commentPreviewInlinePending: false, _inlineStartRetryPending: false, _inlineStartTries: 0, commentPreviewManualRevealed: false, _scrollPreviewOnDock: false, _inplaceScrollHandler: undefined, _inplaceRafPending: false, _widthSyncTargets: null, _widthSyncRaf: 0, previewFloatPos: null, _qrResizeObs: undefined, req: undefined, selected: undefined, currentCaptcha: null, errorCount: 0, mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/vnd.adobe.flash.movie', 'application/x-shockwave-flash', 'video/webm', 'video/mp4'], validExtension: /\.(jpe?g|png|gif|pdf|swf|webm|mp4)$/i, typeFromExtension: { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'pdf': 'application/pdf', 'swf': 'application/vnd.adobe.flash.movie', 'webm': 'video/webm', 'mp4': 'video/mp4' }, extensionFromType: { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'application/pdf': 'pdf', 'application/vnd.adobe.flash.movie': 'swf', 'application/x-shockwave-flash': 'swf', 'video/webm': 'webm', 'video/mp4': 'mp4' }, init() { let sc; if (!Conf['Quick Reply']) { return; } this.posts = []; $.on(d, '4chanXInitFinished', () => BoardConfig.ready(QR.initReady)); Callbacks.Post.push({ name: 'Quick Reply', cb: this.node }); this.shortcut = (sc = $.el('a', { className: 'disabled', title: 'Quick Reply', href: 'javascript:;', })); Icon.set(this.shortcut, 'comment', 'Quick Reply'); $.on(sc, 'click', function () { if (!QR.postingIsEnabled) { return; } const mobile = resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice()); if ((Conf['Persistent QR'] && !mobile) || !QR.nodes || QR.nodes.el.hidden) { QR.open(); QR.nodes.com.focus(); } else { QR.close(); } }); Header.addShortcut('qr', sc, 540); window.addEventListener('message', event => { if (event.data?.twister?.error) { QR.error($.el('div', { innerHTML: event.data.twister.error })); } }); $.on(d, 'MobileSheetOpened', (e) => { if (e.detail !== 'qr' && QR.nodes && !QR.nodes.el.hidden) { QR.close(); } }); }, initReady() { let origToggle; const captchaVersion = $('#t-root') ? 't' : $('#g-recaptcha, #captcha-forced-noscript') ? 'v2' : (g.SITE.software === 'yotsuba' ? 't' : 'v2'); QR.captcha = Captcha[captchaVersion]; QR.postingIsEnabled = true; const { config } = g.BOARD; const prop = (key, def) => +(config[key] ?? def); QR.min_width = prop('min_image_width', 1); QR.min_height = prop('min_image_height', 1); QR.max_width = (QR.max_height = 10000); QR.max_size = prop('max_filesize', 4194304); QR.max_size_video = prop('max_webm_filesize', QR.max_size); QR.max_comment = prop('max_comment_chars', 2000); QR.max_name = prop('max_name_chars', 100); QR.max_email = prop('max_email_chars', 100); QR.max_sub = prop('max_subject_chars', 100); QR.max_width_video = (QR.max_height_video = 2048); QR.max_duration_video = prop('max_webm_duration', 120); QR.forcedAnon = !!config.forced_anon; QR.spoiler = !!config.spoilers; if (origToggle = $.id('togglePostFormLink')) { const link = $.el('h1', { className: "qr-link-container" }); $.extend(link, { innerHTML: `${g.VIEW === "thread" ? "Reply to Thread" : "Start a Thread"}` }); QR.link = link.firstElementChild; $.on(link.firstChild, 'click', function () { QR.open(); return QR.nodes.com.focus(); }); $.before(origToggle, link); origToggle.firstElementChild.textContent = 'Original Form'; if (Conf['Hide Original Post Form'] && g.VIEW === 'index') { $.addClass(doc, 'hide-original-post-form'); $.on(origToggle.firstElementChild, 'click', function () { const shown = $.toggleClass(doc, 'show-original-post-form'); this.textContent = shown ? 'Hide Original Form' : 'Original Form'; }); } } if (g.VIEW === 'thread') { let navLinksBot; const linkBot = $.el('div', { className: "brackets-wrap qr-link-container-bottom" }); $.extend(linkBot, { innerHTML: 'Reply to Thread' }); $.on(linkBot.firstElementChild, 'click', function () { QR.open(); return QR.nodes.com.focus(); }); if (navLinksBot = $('.navLinksBot')) { $.prepend(navLinksBot, linkBot); } } $.on(d, 'QRGetFile', QR.getFile); $.on(d, 'QRDrawFile', QR.drawFile); $.on(d, 'QRSetFile', QR.setFile); $.on(d, 'QRCommentPreviewChanged', QR.applyCommentPreviewSettings); $.on(d, 'PostsInserted', QR.onPostsInsertedPreview); $.on(d, 'QRPostSuccessful', QR.removeThreadPreviewPost); document.addEventListener('mousedown', QR.onPreviewInlineToggleMouseDown, true); $.on(d, 'click', QR.onPreviewInlineToggleClick); $.sync('Comment Preview', (value) => { Conf['Comment Preview'] = !!value; QR.applyCommentPreviewSettings(); }); $.sync('Comment Preview Default Mode', (value) => { Conf['Comment Preview Default Mode'] = QR.normalizeCommentPreviewDefaultMode(value); QR.commentPreviewModeInitialized = false; QR.applyCommentPreviewSettings(); }); $.sync('Comment Preview Attach Location', (value) => { Conf['Comment Preview Attach Location'] = QR.normalizeCommentPreviewAttachLocation(value); QR.repositionFloatingPreview(); }); $.sync('Comment Preview Inline Behavior', (value) => { Conf['Comment Preview Inline Behavior'] = value === 'inplace' ? 'inplace' : 'scroll'; QR.applyCommentPreviewSettings(); }); $.sync('Comment Preview Remember Float Position', (value) => { Conf['Comment Preview Remember Float Position'] = !!value; if (!QR.commentPreviewRemembersFloat()) { QR.clearStoredCommentPreviewFloatPos(false); } }); $.sync('Comment Preview Thread Behavior', (value) => { Conf['Comment Preview Thread Behavior'] = QR.normalizeCommentPreviewVisibility(value); QR.applyCommentPreviewSettings(); }); $.sync('Comment Preview Catalog Behavior', (value) => { Conf['Comment Preview Catalog Behavior'] = QR.normalizeCommentPreviewVisibility(value); QR.applyCommentPreviewSettings(); }); $.sync('Show Comment Preview Header Icon', (value) => { Conf['Show Comment Preview Header Icon'] = value !== false; QR.applyCommentPreviewSettings(); }); $.sync('Show QR Drafts Icon', (value) => { Conf['Show QR Drafts Icon'] = value !== false; QR.drafts.updateButton(); }); $.sync('Allow Browser Autofill', (value) => { Conf['Allow Browser Autofill'] = !!value; QR.disablePersonaFieldAutofill(); }); $.on(d, 'paste', QR.paste); $.on(d, 'dragover', QR.dragOver); $.on(d, 'drop', QR.dropFile); $.on(d, 'dragstart dragend', QR.drag); $.on(d, 'IndexRefreshInternal', QR.generatePostableThreadsList); $.on(d, 'ThreadUpdate', QR.statusCheck); $.on(d, 'ThreadUpdate', QR.drafts.onThreadUpdate); $.on(d, '4chanXQRMove', QR.repositionFloatingPreview); $.on(window, 'resize', QR.repositionFloatingPreview); if (!Conf['Persistent QR'] || resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { return; } QR.open(); if (Conf['Auto Hide QR']) { return QR.hide(); } }, statusCheck() { if (!QR.nodes) { return; } const { thread } = QR.posts[0]; if ((thread !== 'new') && g.threads.get(`${g.BOARD}.${thread}`).isDead) { return QR.abort(); } else { return QR.status(); } }, node() { $.on(this.nodes.quote, 'click', QR.quote); if (this.isFetchedQuote) { return QR.generatePostableThreadsList(); } }, open() { if (QR._opening) { return; } QR._opening = true; try { return QR._open(); } finally { QR._opening = false; } }, _opening: false, _open() { if (QR.nodes) { const wasHidden = QR.nodes.el.hidden; if (wasHidden) { QR.captcha.setup(); } QR.nodes.el.hidden = false; QR.unhide(); if (wasHidden) { QR.drafts.restore(); } QR.applyCommentPreviewSettings(); } else { try { QR.dialog(); } catch (err) { delete QR.nodes; Main.handleErrors({ message: 'Quick Reply dialog creation crashed.', error: err }); return; } } $.addClass(doc, 'xt-qr-open'); if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { $.event('MobileSheetOpened', 'qr'); } return $.rmClass(QR.shortcut, 'disabled'); }, close() { if (QR.req) { QR.abort(); return; } QR.nodes.el.hidden = true; $.rmClass(doc, 'xt-qr-open'); QR.cleanNotifications(); QR.blur(); $.rmClass(QR.nodes.el, 'dump'); $.addClass(QR.shortcut, 'disabled'); QR.drafts.flush(); QR.storeCommentPreviewLastMode(); QR.removeThreadPreviewPost(); QR.removeFloatingPreview(); if (!QR.commentPreviewRemembersFloat()) { QR.clearStoredCommentPreviewFloatPos(); } QR.previewInline = false; QR.commentPreviewModeInitialized = false; QR.commentPreviewDefaultModeApplied = ''; QR.commentPreviewRestoreDetachedFloat = false; QR.commentPreviewInlinePending = false; QR.commentPreviewManualRevealed = false; QR._inlineStartTries = 0; QR._scrollPreviewOnDock = false; new QR.post(true); for (var post of QR.posts.splice(0, QR.posts.length - 1)) { post.delete(); } QR.cooldown.auto = false; QR.status(); return QR.captcha.destroy(); }, focus() { return $.queueTask(function () { if (!QR.inBubble()) { QR.hasFocus = !!d.activeElement && QR.nodes.el.contains(d.activeElement); return QR.nodes.el.classList.toggle('focus', QR.hasFocus); } }); }, inBubble() { const bubbles = $$('iframe[src^="https://www.google.com/recaptcha/api2/frame"]'); return bubbles.includes(d.activeElement) || bubbles.some(el => (getComputedStyle(el).visibility !== 'hidden') && (el.getBoundingClientRect().bottom > 0)); }, hide() { QR.blur(); $.addClass(QR.nodes.el, 'autohide'); return QR.nodes.autohide.checked = true; }, unhide() { $.rmClass(QR.nodes.el, 'autohide'); return QR.nodes.autohide.checked = false; }, toggleHide() { if (this.checked) { return QR.hide(); } else { return QR.unhide(); } }, blur() { if (QR.nodes.el.contains(d.activeElement)) { return d.activeElement.blur(); } }, toggleSJIS(e) { e.preventDefault(); Conf['sjisPreview'] = !Conf['sjisPreview']; $.set('sjisPreview', Conf['sjisPreview']); return QR.nodes.el.classList.toggle('sjis-preview', Conf['sjisPreview']); }, toggleCommentPreview(e) { e.preventDefault(); if (Conf['Comment Preview'] && QR.commentPreviewVisibility() === 'manual') { QR.commentPreviewManualRevealed = !QR.commentPreviewManualRevealed; return $.event('QRCommentPreviewChanged', null); } Conf['Comment Preview'] = !Conf['Comment Preview']; $.set('Comment Preview', Conf['Comment Preview']); if (Conf['Comment Preview'] && QR.commentPreviewVisibility() === 'manual') { QR.commentPreviewManualRevealed = true; } return $.event('QRCommentPreviewChanged', null); }, onPreviewInlineToggleMouseDown(e) { const t = e.target; if (t && t.closest && t.closest('.qr-preview-inline-toggle, .qr-preview-gutter-toggle')) { e.stopPropagation(); e.preventDefault(); } }, onPreviewInlineToggleClick(e) { const t = e.target; if (t && t.closest && t.closest('.qr-preview-inline-toggle, .qr-preview-gutter-toggle')) { QR.toggleInlinePreview(e); } }, toggleInlinePreview(e) { e?.preventDefault?.(); e?.stopPropagation?.(); QR.previewInline = !QR.previewInline; if (QR.previewInline && !QR.canActuallyShowThreadPreview()) { QR.previewInline = false; } if (QR.previewInline) { QR._scrollPreviewOnDock = true; } QR.refreshCommentPreview(); QR.storeCommentPreviewLastMode(); }, texPreviewShow() { if ($.hasClass(QR.nodes.el, 'tex-preview')) { return QR.texPreviewHide(); } $.addClass(QR.nodes.el, 'tex-preview'); QR.nodes.texPreview.innerHTML = QR.renderComPreview(QR.nodes.com.value); return QR.typesetMathjax(QR.nodes.texPreview); }, texPreviewHide() { return $.rmClass(QR.nodes.el, 'tex-preview'); }, updateComPreview() { if (QR.usingThreadPreview()) { QR.updateThreadPreviewPost(); return; } if (QR.usingFloatingPreview()) { QR.updateFloatingPreview(); return; } if (!QR.nodes?.comPreview) return; QR.updateComPreviewQuoteColor(); QR.nodes.comPreview.innerHTML = QR.renderComPreview(QR.nodes.com.value); if (g.BOARD.config.math_tags && /\[(math|eqn)\]/.test(QR.nodes.com.value)) { QR.typesetMathjax(QR.nodes.comPreview); } }, typesetMathjax(node) { if (!node.id) return; $.global('typesetMathjax', { id: node.id }); }, updateComPreviewQuoteColor() { if (!QR.nodes?.comPreview) return; const sample = $.el('blockquote', { className: 'postMessage', innerHTML: '>quote' }); sample.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;left:-9999px;top:-9999px;'; $.add(d.body, sample); const quote = $('.quote', sample); const color = quote ? getComputedStyle(quote).color : ''; $.rm(sample); if (color) { QR.nodes.comPreview.style.setProperty('--xt-qr-com-preview-quote-color', color); } }, usingThreadPreview() { return !!Conf['Comment Preview'] && QR.previewInline === true && QR.canActuallyShowThreadPreview() && !QR.shouldSuppressCommentPreview(); }, usingFloatingPreview() { if (!Conf['Comment Preview'] || QR.usingThreadPreview() || QR.commentPreviewInlinePending) return false; if (QR.shouldSuppressCommentPreview()) return false; return true; }, normalizeCommentPreviewVisibility(value) { return value === 'until-content' || value === 'manual' ? value : 'normal'; }, commentPreviewVisibility() { const key = g.VIEW === 'thread' ? 'Comment Preview Thread Behavior' : 'Comment Preview Catalog Behavior'; return QR.normalizeCommentPreviewVisibility(Conf[key]); }, commentPreviewHasContent() { if ((QR.nodes?.com?.value || '').trim()) return true; return !!(QR.selected?.file || QR.selected?.pendingFile); }, shouldSuppressCommentPreview() { if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { return true; } switch (QR.commentPreviewVisibility()) { case 'manual': return !QR.commentPreviewManualRevealed; case 'until-content': return !QR.commentPreviewHasContent(); default: return false; } }, commentPreviewManualHidden() { return QR.commentPreviewVisibility() === 'manual' && !QR.commentPreviewManualRevealed; }, canActuallyShowThreadPreview() { if (g.VIEW !== 'thread') return false; const currentThread = `${g.THREADID || g.threadID || ''}`; if (!currentThread) return false; const qrThread = QR.posts?.[0]?.thread; if (qrThread && qrThread !== 'new' && `${qrThread}` === currentThread) return true; const uiThread = QR.nodes?.thread?.value; if (uiThread && uiThread !== 'new' && `${uiThread}` === currentThread) return true; return false; }, normalizeCommentPreviewDefaultMode(value) { return value === 'inline' || value === 'remember' ? value : 'attached'; }, normalizeCommentPreviewAttachLocation(value) { return value === 'bottom' || value === 'top' || value === 'left' || value === 'right' ? value : 'auto'; }, normalizeCommentPreviewMode(value) { return value === 'inline' || value === 'detached' ? value : 'attached'; }, commentPreviewDefaultMode() { return QR.normalizeCommentPreviewDefaultMode(Conf['Comment Preview Default Mode']); }, commentPreviewRemembersFloat() { return !!Conf['Comment Preview Remember Float Position'] || QR.commentPreviewDefaultMode() === 'remember'; }, commentPreviewLastMode() { return QR.normalizeCommentPreviewMode(Conf['Comment Preview Last Mode']); }, storeCommentPreviewLastMode(mode) { const normalized = QR.normalizeCommentPreviewMode(mode || QR.currentCommentPreviewMode()); Conf['Comment Preview Last Mode'] = normalized; $.set('Comment Preview Last Mode', normalized); }, currentCommentPreviewMode() { if (QR.usingThreadPreview()) return 'inline'; if (QR.previewFloat?.dataset.userDragged === 'true' || QR.previewFloatPos) return 'detached'; return 'attached'; }, parseCommentPreviewFloatPos(raw) { if (!raw || typeof raw !== 'object') return null; const { left, top } = raw; return typeof left === 'string' && typeof top === 'string' && left && top ? { left, top } : null; }, rememberedCommentPreviewFloatPos() { if (QR.previewFloatPos) return QR.previewFloatPos; if (!QR.commentPreviewRemembersFloat()) return null; const wantsDetached = QR.commentPreviewRestoreDetachedFloat || (QR.commentPreviewDefaultMode() === 'remember' && QR.commentPreviewLastMode() === 'detached'); return wantsDetached ? QR.parseCommentPreviewFloatPos(Conf['Comment Preview Float Position']) : null; }, setCommentPreviewFloatPos(pos) { QR.previewFloatPos = pos; if (QR.commentPreviewRemembersFloat()) { Conf['Comment Preview Float Position'] = pos; $.set('Comment Preview Float Position', pos); } }, clearStoredCommentPreviewFloatPos(clearRuntime = true) { if (clearRuntime) { QR.previewFloatPos = null; } Conf['Comment Preview Float Position'] = {}; $.set('Comment Preview Float Position', {}); }, ensureCommentPreviewStartMode() { const defaultMode = QR.commentPreviewDefaultMode(); if (QR.commentPreviewModeInitialized && QR.commentPreviewDefaultModeApplied === defaultMode) return; QR.commentPreviewModeInitialized = true; QR.commentPreviewDefaultModeApplied = defaultMode; const wantedMode = defaultMode === 'remember' ? QR.commentPreviewLastMode() : defaultMode; const savedFloatPos = Conf['Comment Preview Remember Float Position'] ? QR.parseCommentPreviewFloatPos(Conf['Comment Preview Float Position']) : null; QR.commentPreviewRestoreDetachedFloat = wantedMode === 'detached' || (defaultMode === 'attached' && !!savedFloatPos); if (wantedMode === 'inline') { if (QR.canActuallyShowThreadPreview()) { QR.commentPreviewInlinePending = false; QR.previewInline = true; return; } const cur = `${g.THREADID || g.threadID || ''}`; const tgt = `${QR.posts?.[0]?.thread ?? ''}`; const uiTgt = `${QR.nodes?.thread?.value ?? ''}`; const otherThread = (v) => !!v && v !== 'new' && v !== cur; if (g.VIEW === 'thread' && cur && !otherThread(tgt) && !otherThread(uiTgt)) { QR.commentPreviewInlinePending = true; QR.previewInline = false; QR.commentPreviewModeInitialized = false; QR.commentPreviewDefaultModeApplied = ''; QR.scheduleInlineStartRetry(); return; } QR.commentPreviewInlinePending = false; } else { QR.commentPreviewInlinePending = false; } QR.previewInline = false; if (QR.commentPreviewRestoreDetachedFloat) { const pos = QR.rememberedCommentPreviewFloatPos(); if (pos && QR.previewFloat) { QR.previewFloat.style.left = pos.left; QR.previewFloat.style.top = pos.top; QR.previewFloat.style.right = ''; QR.previewFloat.style.bottom = ''; QR.previewFloat.dataset.userDragged = 'true'; QR.previewFloat.title = 'Double-click to attach to QR'; } } else { QR.previewFloatPos = null; if (QR.previewFloat) { delete QR.previewFloat.dataset.userDragged; QR.previewFloat.removeAttribute('title'); } } }, scheduleInlineStartRetry() { if (QR._inlineStartRetryPending) return; QR._inlineStartRetryPending = true; const tick = () => { QR._inlineStartRetryPending = false; if (!Conf['Comment Preview'] || !QR.nodes?.el || QR.nodes.el.hidden || QR.commentPreviewModeInitialized) { QR._inlineStartTries = 0; return; } if (QR._inlineStartTries++ >= 40) { QR._inlineStartTries = 0; QR.commentPreviewInlinePending = false; QR.previewInline = false; QR.commentPreviewModeInitialized = true; QR.commentPreviewDefaultModeApplied = QR.commentPreviewDefaultMode(); QR.refreshCommentPreview(); return; } QR.applyCommentPreviewSettings(); if (!QR.commentPreviewModeInitialized) { QR._inlineStartRetryPending = true; setTimeout(tick, 50); } else { QR._inlineStartTries = 0; } }; setTimeout(tick, 30); }, refreshCommentPreview() { if (!QR.nodes?.el || QR.nodes.el.hidden) { QR.removeThreadPreviewPost(); QR.removeFloatingPreview(); return; } const wantThread = QR.usingThreadPreview(); const wantFloat = QR.usingFloatingPreview(); if (wantThread) { QR.removeFloatingPreview(); QR.updateThreadPreviewPost(); } else if (wantFloat) { QR.removeThreadPreviewPost(); QR.updateFloatingPreview(); } else if (Conf['Comment Preview']) { QR.removeThreadPreviewPost(); QR.removeFloatingPreview(); QR.updateComPreview(); } else { QR.removeThreadPreviewPost(); QR.removeFloatingPreview(); } }, applyCommentPreviewSettings() { if (!QR.nodes?.el || !QR.nodes?.com) return; const { classList } = QR.nodes.el; const enabled = !!Conf['Comment Preview']; if (!QR.commentPreviewRemembersFloat() && QR.parseCommentPreviewFloatPos(Conf['Comment Preview Float Position'])) { QR.clearStoredCommentPreviewFloatPos(false); } if (enabled) { QR.ensureCommentPreviewStartMode(); } const isThread = QR.usingThreadPreview(); const isFloating = QR.usingFloatingPreview(); classList.toggle('has-com-preview', false); classList.remove('com-preview-below', 'com-preview-right', 'com-preview-left', 'com-preview-thread'); if (isThread) { classList.add('com-preview-thread'); } const iconActive = enabled && !QR.commentPreviewManualHidden(); QR.nodes.previewToggle?.classList.toggle('enabled', iconActive); QR.nodes.previewToggle?.setAttribute('aria-pressed', iconActive ? 'true' : 'false'); if (QR.nodes.previewToggle) { QR.nodes.previewToggle.hidden = Conf['Show Comment Preview Header Icon'] === false; } if (!enabled || !isThread) { QR.stopInplaceFollow(); QR.removeThreadPreviewPost(); } if (!enabled || !isFloating) { QR.removeFloatingPreview(); } if (enabled) { QR.ensurePersonaPreviewListeners(); if (isThread) { QR.updateThreadPreviewPost(); } else if (isFloating) { QR.updateFloatingPreview(); } else { QR.updateComPreview(); } } }, personaPreviewListenersBound: false, ensurePersonaPreviewListeners() { if (QR.personaPreviewListenersBound || !QR.nodes) return; const fields = [QR.nodes.name, QR.nodes.sub, QR.nodes.email].filter(Boolean); for (const f of fields) { $.on(f, 'input', QR.onPersonaFieldInputForPreview); $.on(f, 'change', QR.onPersonaFieldInputForPreview); } if (QR.nodes.thread) { $.on(QR.nodes.thread, 'change', QR.onQRThreadChangeForPreview); } $.on(d, 'QRSetFile', QR.onQRFileEventForPreview); $.on(d, 'QRFileRemoved', QR.onQRFileEventForPreview); QR.personaPreviewListenersBound = true; }, onPersonaFieldInputForPreview() { QR.refreshCommentPreview(); }, onQRFileEventForPreview() { QR.refreshCommentPreview(); }, onQRThreadChangeForPreview() { if (Conf['Comment Preview'] && !QR.commentPreviewModeInitialized) { QR.applyCommentPreviewSettings(); return; } QR.refreshCommentPreview(); }, onPostsInsertedPreview() { QR.repositionThreadPreviewPost(); if (QR.nodes?.el) { if (Conf['Comment Preview'] && !QR.commentPreviewModeInitialized) { QR.applyCommentPreviewSettings(); } else { QR.refreshCommentPreview(); } } }, getThreadPreviewRoot() { const threadEl = $(g.SITE?.selectors?.thread || '.thread'); if (threadEl) return threadEl; return document.querySelector('.thread'); }, shouldShowThreadPreview() { if (!QR.usingThreadPreview()) return false; return QR.canActuallyShowThreadPreview(); }, createThreadPreviewPost(includeSideArrows = false) { const container = $.el('div', { className: 'postContainer replyContainer qr-preview-post', }); const now = new Date(); const utc = Math.floor(now.getTime() / 1000); const timeStr = now.toLocaleString([], { month: '2-digit', day: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(',', ''); const sideArrows = includeSideArrows ? `` : ''; container.innerHTML = ` ${sideArrows}
`; container.dataset.previewPost = 'true'; QR.refreshInlineToggleLabel(container); return container; }, refreshInlineToggleLabel(container) { const inThreadView = g.VIEW === 'thread'; const toFloat = QR.previewInline; for (const link of $$('.qr-preview-inline-toggle', container)) { if (!inThreadView) { link.textContent = 'preview'; link.title = 'Comment preview'; link.classList.toggle('is-dockable', false); continue; } link.textContent = toFloat ? 'undock preview' : 'dock preview'; link.title = toFloat ? 'Undock: pop the preview back out to a floating window' : 'Dock the preview into the thread'; link.classList.toggle('is-dockable', true); } }, updateThreadPreviewPost() { if (!QR.shouldShowThreadPreview()) { QR.removeThreadPreviewPost(); return; } QR.removeFloatingPreview(); const root = QR.getThreadPreviewRoot(); if (!root) { setTimeout(() => QR.updateThreadPreviewPost(), 120); return; } const isNew = !QR.previewPost; if (isNew) { QR.previewPost = QR.createThreadPreviewPost(true); $.add(root, QR.previewPost); } QR.populatePreviewPost(QR.previewPost); if (Conf['Comment Preview Inline Behavior'] === 'inplace') { QR.startInplaceFollow(); } else { QR.stopInplaceFollow(); QR.repositionThreadPreviewPost(); if (QR._scrollPreviewOnDock) { QR._scrollPreviewOnDock = false; QR.previewPost.scrollIntoView({ behavior: 'smooth', block: 'end' }); } } }, populatePreviewPost(container) { const postEl = $('.post', container); const desktopInfo = $('.postInfo.desktop', container) || container; const nameBlock = $('.nameBlock', desktopInfo) || $('.nameBlock', container); const dateEl = $('.dateTime', desktopInfo) || $('.dateTime', container); const msg = $('.postMessage', container); const nameVal = (QR.nodes.name?.value || '').trim() || 'Anonymous'; const subVal = (QR.nodes.sub?.value || '').trim(); const emailVal = (QR.nodes.email?.value || '').trim(); let nameHTML = `${E(nameVal)}`; if (subVal) { const subj = $.el('span', { className: 'subject', textContent: subVal }); if (nameBlock.parentNode) { const existingSub = $('.subject', container); if (existingSub) $.rm(existingSub); $.before(nameBlock, subj); } } else { const existingSub = $('.subject', container); if (existingSub) $.rm(existingSub); } if (emailVal) { nameHTML = `${nameHTML}`; } nameBlock.innerHTML = nameHTML; const now = new Date(); dateEl.textContent = now.toLocaleString([], { month: '2-digit', day: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }); msg.innerHTML = QR.renderComPreview(QR.nodes.com.value || ''); if (g.BOARD.config.math_tags && /\[(math|eqn)\]/.test(QR.nodes.com.value || '')) { QR.typesetMathjax(msg); } msg.style.whiteSpace = 'pre-wrap'; msg.style.overflowWrap = 'normal'; msg.style.wordBreak = 'normal'; QR.updatePreviewFileIndicator(container, postEl); QR.scheduleWidthSync(container); QR.refreshInlineToggleLabel(container); }, scheduleWidthSync(container) { if (!container) return; (QR._widthSyncTargets || (QR._widthSyncTargets = new Set())).add(container); if (QR._widthSyncRaf) return; QR._widthSyncRaf = requestAnimationFrame(() => { QR._widthSyncRaf = 0; const targets = QR._widthSyncTargets; QR._widthSyncTargets = null; if (!targets) return; for (const el of targets) { if (el.isConnected) QR.syncFloatingPreviewWidth(el); } }); }, syncFloatingPreviewWidth(container) { const float = container.closest('.qr-preview-float'); const shell = container.closest('.qr-preview-post'); const reply = $('.reply', container); if (!reply) return; const measureRoot = $.el('div', { className: float?.className || '', style: 'position:fixed;visibility:hidden;pointer-events:none;left:-10000px;top:0;width:auto;max-width:none;z-index:-1;' }); const cloneShell = (shell || container).cloneNode(true); cloneShell.style.width = 'auto'; cloneShell.style.maxWidth = 'none'; const cloneReply = $('.reply', cloneShell); if (cloneReply) { cloneReply.style.width = 'auto'; cloneReply.style.maxWidth = 'none'; cloneReply.style.display = 'inline-block'; } const cloneMsg = $('.postMessage', cloneShell); if (cloneMsg) { cloneMsg.style.whiteSpace = 'pre'; cloneMsg.style.overflowWrap = 'normal'; cloneMsg.style.wordBreak = 'normal'; cloneMsg.style.maxWidth = 'none'; } $.add(measureRoot, cloneShell); $.add(d.body, measureRoot); const px = (value) => parseFloat(value) || 0; const measuredWidth = (el) => { if (!el) return 0; const style = getComputedStyle(el); return Math.max(el.scrollWidth, el.getBoundingClientRect().width, px(style.width), px(style.minWidth)); }; const horizontalExtras = (el, includePadding = false) => { if (!el) return 0; const style = getComputedStyle(el); let total = px(style.marginLeft) + px(style.marginRight); if (includePadding) { total += px(style.paddingLeft) + px(style.paddingRight) + px(style.borderLeftWidth) + px(style.borderRightWidth); } return total; }; const cloneThumb = $('.fileThumb', cloneShell); const cloneFileText = $('.fileText', cloneShell); const messageWidth = Math.ceil(measuredWidth(cloneMsg)); const thumbWidth = cloneThumb ? Math.ceil(measuredWidth(cloneThumb) + horizontalExtras(cloneThumb)) : 0; const fileTextWidth = cloneFileText ? Math.ceil(measuredWidth(cloneFileText) + horizontalExtras(cloneFileText)) : 0; const replyExtras = horizontalExtras(cloneReply, true); const sideBySideWidth = thumbWidth + messageWidth + horizontalExtras(cloneMsg) + replyExtras; const natural = Math.ceil(Math.max(measuredWidth(cloneReply), fileTextWidth + replyExtras, sideBySideWidth)); $.rm(measureRoot); const visibleMsg = $('.postMessage', container); const setWrapping = (isCapped) => { if (!visibleMsg) return; visibleMsg.style.overflowWrap = isCapped ? 'anywhere' : 'normal'; visibleMsg.style.wordBreak = isCapped ? 'break-word' : 'normal'; }; if (!float) { const minInlineWidth = 180; const viewportPad = 8; const shellRect = shell?.getBoundingClientRect(); const parentRect = shell?.parentElement?.getBoundingClientRect(); const gutter = shell ? $('.replacedSideArrows, .sideArrows', shell) : null; const gutterRect = gutter?.getBoundingClientRect(); const gutterChild = gutter?.firstElementChild; const gutterChildRect = gutterChild?.getBoundingClientRect(); const gutterVisualRight = Math.max(gutterRect?.right || 0, gutterChildRect?.right || 0); const gutterRight = gutterVisualRight ? gutterVisualRight + horizontalExtras(gutter) + horizontalExtras(gutterChild) : 0; const replyLeft = Math.max(viewportPad, gutterRight, shellRect?.left || 0); const rightEdge = Math.min(window.innerWidth - viewportPad, parentRect?.right || window.innerWidth - viewportPad); const inlineCap = Math.max(minInlineWidth, Math.floor(rightEdge - replyLeft)); const isCapped = natural > inlineCap; setWrapping(isCapped); if (shell) shell.style.width = ''; reply.style.width = ''; reply.style.maxWidth = `${inlineCap}px`; return; } const minFloatWidth = 180; const viewportPad = 8; const attachedLoc = float.dataset.userDragged === 'true' ? '' : float.dataset.attachLocation || ''; const qrRect = attachedLoc && QR.nodes?.el ? QR.nodes.el.getBoundingClientRect() : null; const viewportWidth = d.documentElement.clientWidth || window.innerWidth; let viewportCap; if (qrRect && attachedLoc === 'left') { viewportCap = Math.max(minFloatWidth, Math.floor(qrRect.left - viewportPad - 2)); } else if (qrRect && attachedLoc === 'right') { viewportCap = Math.max(minFloatWidth, Math.floor(viewportWidth - qrRect.right - viewportPad - 2)); } else if (qrRect && (attachedLoc === 'top' || attachedLoc === 'bottom')) { viewportCap = Math.max(minFloatWidth, Math.floor(viewportWidth - Math.max(viewportPad, qrRect.left) - viewportPad)); } else { const floatLeft = float.getBoundingClientRect().left || px(float.style.left); viewportCap = Math.max(minFloatWidth, Math.floor(viewportWidth - Math.max(viewportPad, floatLeft) - viewportPad)); } const isCapped = natural > viewportCap; const width = Math.max(minFloatWidth, Math.min(natural, viewportCap)); setWrapping(isCapped); float.style.width = `${width}px`; if (shell) shell.style.width = `${width}px`; reply.style.maxWidth = 'none'; reply.style.width = `${width}px`; if (qrRect && attachedLoc) { QR.applyFloatingPreviewAttachedPosition(float, attachedLoc, qrRect); } }, updatePreviewFileIndicator(rootForQuery, postElForAppend) { const container = rootForQuery; const sel = QR.selected; const file = sel && sel.file; const hasPending = !!(sel && sel.pendingFile); const hasFile = !!file || hasPending; const targetParent = postElForAppend || $('.post', container) || container; const msg = $('.postMessage', targetParent); const fname = (QR.nodes?.filename?.value || (file && file.name) || (hasPending ? 'file' : 'file')).toString(); const sizeStr = file ? ($.bytesToString?.(file.size) || '') : (hasPending ? '' : ''); const fileKey = hasFile ? [ fname, file?.name || '', file?.size || 0, file?.lastModified || 0, file?.type || '', hasPending ? 'pending' : 'ready' ].join('\x1f') : ''; const removePreviewFileBlocks = () => { $$('.qr-preview-file-block, .qr-preview-file-note', container).forEach((el) => { $$('img, video', el).forEach((m) => { if (m.src && m.src.startsWith('blob:')) { try { URL.revokeObjectURL(m.src); } catch { } } }); if (el.dataset.previewBlobUrl) { try { URL.revokeObjectURL(el.dataset.previewBlobUrl); } catch { } } $.rm(el); }); $$('.qr-preview-file-thumb', container).forEach((el) => { if (!el.closest('.qr-preview-file-block')) { if (el.dataset.previewBlobUrl) { try { URL.revokeObjectURL(el.dataset.previewBlobUrl); } catch { } } $.rm(el); } }); }; if (!hasFile) { removePreviewFileBlocks(); return; } const existingBlock = $('.qr-preview-file-block', container); if (existingBlock?.dataset.previewFileKey === fileKey) { if (msg && existingBlock.nextElementSibling !== msg) { $.before(msg, existingBlock); } return; } removePreviewFileBlocks(); const block = $.el('div', { className: 'file qr-preview-file-block' }); block.dataset.previewFileKey = fileKey; const fileText = $.el('div', { className: 'fileText' }); fileText.innerHTML = `File: ${E(fname)}${sizeStr ? ` (${E(sizeStr)})` : ''}`; $.add(block, fileText); const isImage = file && /^image\//.test(file.type); const isVideo = file && /^video\//.test(file.type); let thumbLink = null; if ((isImage || isVideo) && file) { thumbLink = $.el('a', { className: 'fileThumb qr-preview-file-thumb' }); thumbLink.style.minWidth = '125px'; let media; const url = URL.createObjectURL(file); thumbLink.dataset.previewBlobUrl = url; if (isVideo) { media = $.el('video', { src: url, muted: true, loop: true, playsInline: true, style: 'max-width: 125px; max-height: 125px; display: block;' }); } else { media = $.el('img', { src: url, alt: fname, style: 'max-width: 125px; max-height: 125px; display: block;' }); } $.on(media, isVideo ? 'loadedmetadata' : 'load', () => QR.scheduleWidthSync(container)); $.add(thumbLink, media); } if (thumbLink) $.add(block, thumbLink); if (msg && msg.parentNode) { $.before(msg, block); } else { $.add(targetParent, block); } }, repositionThreadPreviewPost() { if (!QR.previewPost) return; if (Conf['Comment Preview Inline Behavior'] === 'inplace') return; const root = QR.getThreadPreviewRoot(); if (!root) return; if (root.lastElementChild !== QR.previewPost) { $.add(root, QR.previewPost); } }, removeThreadPreviewPost() { QR.stopInplaceFollow(); if (QR.previewPost && QR.previewPost.parentNode) { QR.revokePreviewFileBlobs(QR.previewPost); $.rm(QR.previewPost); } QR.previewPost = null; }, startInplaceFollow() { if (QR._inplaceScrollHandler) { QR.relocateInplacePreview(); return; } const handler = () => { if (QR._inplaceRafPending) return; QR._inplaceRafPending = true; requestAnimationFrame(QR.relocateInplacePreview); }; QR._inplaceScrollHandler = handler; window.addEventListener('scroll', handler, { passive: true }); window.addEventListener('resize', handler, { passive: true }); QR.relocateInplacePreview(); }, stopInplaceFollow() { if (!QR._inplaceScrollHandler) return; window.removeEventListener('scroll', QR._inplaceScrollHandler); window.removeEventListener('resize', QR._inplaceScrollHandler); QR._inplaceScrollHandler = undefined; QR._inplaceRafPending = false; }, relocateInplacePreview() { QR._inplaceRafPending = false; if (!QR.previewPost || !QR.usingThreadPreview() || Conf['Comment Preview Inline Behavior'] !== 'inplace') { QR.stopInplaceFollow(); return; } const root = QR.getThreadPreviewRoot(); if (!root) return; const sel = g.SITE?.selectors?.replyOriginal || '.replyContainer:not([data-clone])'; const vh = window.innerHeight; const replies = $$(sel, root).filter(r => r !== QR.previewPost); const lastReply = replies[replies.length - 1] || null; let target = null; for (const reply of replies) { if (reply.getBoundingClientRect().top < vh) { target = reply; } else { break; } } if (lastReply && lastReply.getBoundingClientRect().bottom <= vh) { if (lastReply.nextElementSibling !== QR.previewPost) { $.after(lastReply, QR.previewPost); } } else if (target) { if (target.previousElementSibling !== QR.previewPost) { $.before(target, QR.previewPost); } } else { const pcSel = g.SITE?.selectors?.postContainer || '.postContainer'; let op = null; for (const pc of $$(pcSel, root)) { if (pc === QR.previewPost || pc.hasAttribute('data-clone')) continue; op = pc; break; } if (op) { if (op.nextElementSibling !== QR.previewPost) $.after(op, QR.previewPost); } else if (root.firstElementChild && root.firstElementChild !== QR.previewPost) { $.prepend(root, QR.previewPost); } } QR.scheduleWidthSync(QR.previewPost); }, createFloatingPreview() { const float = $.el('div', { className: 'qr-preview-float', style: 'position:fixed;' }); const shell = QR.createThreadPreviewPost(false); $.add(float, shell); $.add(d.body, float); const rememberedPos = QR.rememberedCommentPreviewFloatPos(); if (rememberedPos) { QR.previewFloatPos = rememberedPos; float.style.left = rememberedPos.left; float.style.top = rememberedPos.top; float.style.right = ''; float.style.bottom = ''; float.dataset.userDragged = 'true'; float.title = 'Double-click to attach to QR'; } QR.attachDragToFloatingPreview(float); return float; }, updateFloatingPreview() { if (!QR.usingFloatingPreview()) { QR.removeFloatingPreview(); return; } QR.removeThreadPreviewPost(); if (!QR.previewFloat) { QR.previewFloat = QR.createFloatingPreview(); } QR.previewFloat.hidden = false; if (QR.nodes?.el && QR.previewFloat.dataset.userDragged !== 'true') { QR.positionFloatingPreviewNearQR(QR.previewFloat); } const postShell = $('.qr-preview-post', QR.previewFloat); if (postShell) { QR.populatePreviewPost(postShell); } }, resolvedCommentPreviewAttachLocation() { const preferred = QR.normalizeCommentPreviewAttachLocation(Conf['Comment Preview Attach Location']); if (preferred !== 'auto') return preferred; const blocked = new Set(); if (Conf['Thread Watcher Attach Controls'] !== false && Conf['Thread Watcher Attached']) { const watcherLoc = Conf['Thread Watcher Attach Location']; blocked.add((watcherLoc === 'top' || watcherLoc === 'left' || watcherLoc === 'right') ? watcherLoc : 'bottom'); } return ['bottom', 'right', 'left', 'top'].find(loc => !blocked.has(loc)) || 'bottom'; }, applyFloatingPreviewAttachedPosition(float, loc, qrRect) { const viewportWidth = d.documentElement.clientWidth || window.innerWidth; const viewportHeight = d.documentElement.clientHeight || window.innerHeight; float.style.position = 'fixed'; if (loc === 'top') { float.style.left = `${qrRect.left}px`; float.style.bottom = `${viewportHeight - qrRect.top}px`; float.style.top = ''; float.style.right = ''; } else if (loc === 'left') { float.style.top = `${qrRect.top}px`; float.style.right = `${viewportWidth - qrRect.left + 2}px`; float.style.left = ''; float.style.bottom = ''; } else if (loc === 'right') { float.style.top = `${qrRect.top}px`; float.style.left = `${qrRect.right + 2}px`; float.style.right = ''; float.style.bottom = ''; } else { float.style.left = `${qrRect.left}px`; float.style.top = `${qrRect.bottom}px`; float.style.right = ''; float.style.bottom = ''; } }, positionFloatingPreviewNearQR(float) { if (!QR.nodes?.el || !float) return; const qrRect = QR.nodes.el.getBoundingClientRect(); const loc = QR.resolvedCommentPreviewAttachLocation(); float.dataset.attachLocation = loc; QR.applyFloatingPreviewAttachedPosition(float, loc, qrRect); }, repositionFloatingPreview(e) { const skipWidthSync = e === true || !!(e?.detail?.dragging); const float = QR.previewFloat; if (float && QR.nodes?.el && float.dataset.userDragged !== 'true') { QR.positionFloatingPreviewNearQR(float); } const postShell = float ? $('.qr-preview-post', float) : null; if (!skipWidthSync && postShell) { QR.scheduleWidthSync(postShell); } if (!skipWidthSync && QR.previewPost) { QR.scheduleWidthSync(QR.previewPost); } }, attachDragToFloatingPreview(float) { let dragging = false; let startClientX = 0; let startClientY = 0; let startLeft = 0; let startTop = 0; let moved = false; let widthSyncRaf = 0; const scheduleWidthSync = () => { if (widthSyncRaf) return; widthSyncRaf = requestAnimationFrame(() => { widthSyncRaf = 0; const postShell = $('.qr-preview-post', float); if (postShell) { QR.syncFloatingPreviewWidth(postShell); } }); }; const onMouseDown = (e) => { if (!float.contains(e.target)) return; dragging = true; moved = false; startClientX = e.clientX; startClientY = e.clientY; const rect = float.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; float.style.position = 'fixed'; document.addEventListener('mousemove', onMouseMove, { passive: false }); document.addEventListener('mouseup', onMouseUp, { once: true, passive: false }); e.preventDefault(); e.stopPropagation(); }; const onMouseMove = (e) => { if (!dragging) return; e.preventDefault(); const dx = e.clientX - startClientX; const dy = e.clientY - startClientY; if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return; moved = true; float.style.left = `${startLeft + dx}px`; float.style.top = `${startTop + dy}px`; float.style.right = ''; float.style.bottom = ''; delete float.dataset.attachLocation; }; const onMouseUp = () => { dragging = false; document.removeEventListener('mousemove', onMouseMove); if (moved) { float.dataset.userDragged = 'true'; float.title = 'Double-click to attach to QR'; QR.setCommentPreviewFloatPos({ left: float.style.left, top: float.style.top }); QR.storeCommentPreviewLastMode('detached'); scheduleWidthSync(); } }; float.addEventListener('mousedown', onMouseDown); float.addEventListener('dblclick', (e) => { if (e.target?.closest?.('.qr-preview-inline-toggle')) return; delete float.dataset.userDragged; float.removeAttribute('title'); QR.clearStoredCommentPreviewFloatPos(); QR.storeCommentPreviewLastMode('attached'); QR.positionFloatingPreviewNearQR(float); scheduleWidthSync(); }); }, removeFloatingPreview() { if (QR.previewFloat) { if (QR.previewFloat.dataset.userDragged === 'true') { QR.setCommentPreviewFloatPos({ left: QR.previewFloat.style.left, top: QR.previewFloat.style.top }); } if (QR.previewFloat.parentNode) { QR.revokePreviewFileBlobs(QR.previewFloat); $.rm(QR.previewFloat); } } QR.previewFloat = null; }, revokePreviewFileBlobs(root) { if (!root) return; $$('img, video', root).forEach((m) => { const src = m.src || m.currentSrc; if (src && src.startsWith('blob:')) { try { URL.revokeObjectURL(src); } catch { } } }); $$('[data-preview-blob-url]', root).forEach((el) => { const u = el.dataset.previewBlobUrl; if (u && u.startsWith('blob:')) { try { URL.revokeObjectURL(u); } catch { } } delete el.dataset.previewBlobUrl; }); }, comPreviewTagWraps: { spoiler: { wrap: (i) => `${i}`, format: true }, code: { wrap: (i) => `
${i}
`, format: false }, math: { wrap: (i, fallback = i) => `[math]${fallback}[/math]`, format: false, raw: true }, eqn: { wrap: (i, fallback = i) => `[eqn]${fallback}[/eqn]`, format: false, raw: true }, sjis: { wrap: (i) => `${i}`, format: true }, b: { wrap: (i) => `${i}`, format: true }, i: { wrap: (i) => `${i}`, format: true }, red: { wrap: (i) => `${i}`, format: true }, green: { wrap: (i) => `${i}`, format: true }, blue: { wrap: (i) => `${i}`, format: true }, }, comPreviewBoardExtras: { mu: ['b', 'i', 'red', 'green', 'blue'], qst: ['b', 'i', 'red', 'green', 'blue'], }, supportedTags() { const board = g.BOARD; const config = board?.config; if (!board || !config) return []; const names = []; if (config.spoilers) names.push('spoiler'); if (config.code_tags) names.push('code'); if (config.math_tags) names.push('math', 'eqn'); if (config.sjis_tags) names.push('sjis'); const extras = QR.comPreviewBoardExtras[board.ID] || []; for (const t of extras) if (!names.includes(t)) names.push(t); return names; }, onAutoCloseTag(e) { if (Conf['Auto-close Tags'] === false) return; const inputType = e?.inputType; if (inputType && inputType !== 'insertText') return; const ta = QR.nodes?.com; if (!ta) return; const pos = ta.selectionStart; if (pos !== ta.selectionEnd) return; if (ta.value[pos - 1] !== ']') return; const m = ta.value.slice(0, pos).match(/\[([a-z]+)\]$/i); if (!m) return; const tag = m[1].toLowerCase(); if (!QR.supportedTags().includes(tag)) return; if (ta.value.slice(pos).startsWith(`[/${tag}]`)) return; ta.value = ta.value.slice(0, pos) + `[/${tag}]` + ta.value.slice(pos); ta.setSelectionRange(pos, pos); $.event('input', null, ta); }, renderComPreview(text) { const board = g.BOARD; const config = board.config; const names = []; if (QR.spoiler) names.push('spoiler'); if (config.code_tags) names.push('code'); if (config.math_tags) names.push('math', 'eqn'); if (config.sjis_tags) names.push('sjis'); const extras = QR.comPreviewBoardExtras[board.ID] || []; for (const t of extras) if (!names.includes(t)) names.push(t); if (!names.length) return QR.formatComPreviewText(text); const tagRe = new RegExp(`\\[(${names.join('|')})\\]([\\s\\S]*?)\\[\\/\\1\\]`, 'g'); let html = ''; let last = 0; let m; while ((m = tagRe.exec(text))) { html += QR.formatComPreviewText(text.slice(last, m.index)); const tag = QR.comPreviewTagWraps[m[1]]; const inner = tag.raw ? QR.escapeMathJaxScript(m[2]) : tag.format ? QR.formatComPreviewText(m[2]) : E(m[2]); const fallback = tag.raw ? E(m[2]) : undefined; html += tag.wrap(inner, fallback); last = m.index + m[0].length; } html += QR.formatComPreviewText(text.slice(last)); return html; }, escapeMathJaxScript(text) { return text.replace(/<\/script/gi, '<\\/script'); }, formatComPreviewText(text) { const escaped = E(text); const withQuotes = escaped.replace(/>>(?:\d+|>\/[a-z\d]+\/(?:\d+)?)(?=$|[\s<.,!?;:)])/g, m => QR.renderComPreviewQuoteLink(m)); const withLinks = withQuotes.replace(/(^|[\s(])((?:https?:\/\/|www\.)[^\s<>"']+)/g, (m, prefix, url) => `${prefix}${QR.renderComPreviewLink(url)}`); return withLinks.split('\n').map(line => { const quotePrefix = line.match(/^(>)+(?!>\/[a-z\d]+\/\d+)/)?.[0]; const content = quotePrefix ? `${line}` : line; return content; }).join('
'); }, renderComPreviewQuoteLink(text) { const match = text.match(/^>>(?:(\d+)|>\/([a-z\d]+)\/(\d+)?)$/); if (!match) return text; const board = g.BOARD; const boardID = match[2] || board.ID; const postID = match[1] || match[3]; if (!postID) { const href = Get.url('index', { siteID: board.siteID, boardID }) || `/${boardID}/`; return `${text}`; } const currentThread = `${g.THREADID || g.threadID || ''}`; const threadID = boardID === board.ID ? (currentThread || postID) : postID; const hash = g.SITE.software === 'yotsuba' ? `#p${postID}` : `#${postID}`; const href = boardID === board.ID && postID === currentThread ? hash : `${Get.url('thread', { siteID: board.siteID, boardID, threadID }) || `/${boardID}/thread/${threadID}`}${hash}`; return `${text}`; }, renderComPreviewLink(text) { const trail = text.match(/[.,!?;:)]+$/)?.[0] || ''; const body = trail ? text.slice(0, -trail.length) : text; const href = /^https?:\/\//i.test(body) ? body : `https://${body}`; return `${body}${trail}`; }, addPost() { const wasOpen = (QR.nodes && !QR.nodes.el.hidden); QR.open(); if (wasOpen) { $.addClass(QR.nodes.el, 'dump'); new QR.post(true); } return QR.nodes.com.focus(); }, clearDumpList() { if (!QR.posts?.length) { return; } QR.pendingFiles.length = 0; new QR.post(true); for (var post of QR.posts.splice(0, QR.posts.length - 1)) { post.delete(); } QR.cleanNotifications(); $.rmClass(QR.nodes.el, 'dump'); QR.status(); QR.captcha.updateThread?.(); }, toggleDumpList(e) { if (e.shiftKey) { e.preventDefault(); QR.clearDumpList(); return; } QR.nodes.el.classList.toggle('dump'); }, blurMouseFocusedAction(e) { QR.blurPointerFocusedElement(e.currentTarget, e.detail); }, blurPointerFocusedElement(el, detail) { if (detail > 0) { el?.blur?.(); } }, setCustomCooldown(enabled) { Conf['customCooldownEnabled'] = enabled; QR.cooldown.customCooldown = enabled; return QR.nodes.customCooldown.classList.toggle('disabled', !enabled); }, toggleCustomCooldown() { const enabled = $.hasClass(QR.nodes.customCooldown, 'disabled'); QR.setCustomCooldown(enabled); return $.set('customCooldownEnabled', enabled); }, error(err, focusOverride) { let el; QR.open(); if (typeof err === 'string') { el = $.tn(err); } else { el = err; el.removeAttribute('style'); } const notice = new Notice('warning', el); QR.notifications.push(notice); if (!Header.areNotificationsEnabled) { if (d.hidden && !QR.cooldown.auto) { return alert(el.textContent); } } else if (d.hidden || !(focusOverride || d.hasFocus())) { const notif = new Notification(el.textContent, { body: el.textContent, icon: Favicon.logo }); notif.onclick = () => window.focus(); if ($.engine !== 'gecko') { notif.onclose = () => notice.close(); return notif.onshow = () => setTimeout(function () { notif.onclose = null; return notif.close(); }, 7 * SECOND); } } }, connectionError() { return $.el('span', { innerHTML: 'Connection error while posting. ' + '[More info]' }); }, notifications: [], cleanNotifications() { for (var notification of QR.notifications) { notification.close(); } return QR.notifications = []; }, status() { let disabled, value; if (!QR.nodes) { return; } const { thread } = QR.posts[0]; if ((thread !== 'new') && g.threads.get(`${g.BOARD}.${thread}`).isDead) { value = 'Dead'; disabled = true; QR.cooldown.auto = false; } value = QR.req ? QR.req.progress : QR.cooldown.seconds || value; const { status } = QR.nodes; status.value = !value ? 'Submit' : QR.cooldown.auto ? `Auto ${value}` : value; status.disabled = disabled || false; return status.disabled; }, openPost() { QR.open(); if (QR.selected.isLocked) { const index = QR.posts.indexOf(QR.selected); (QR.posts[index + 1] || new QR.post()).select(); $.addClass(QR.nodes.el, 'dump'); return QR.cooldown.auto = true; } }, quote(e) { let range; e?.preventDefault(); if (!QR.postingIsEnabled) { return; } const sel = d.getSelection(); const post = Get.postFromNode(this); if (!sel || !post) { return; } const { root } = post.nodes; const postRange = new Range(); postRange.selectNode(root); let text = post.board.ID === g.BOARD.ID ? `>>${post}\n` : `>>>/${post.board}/${post}\n`; for (let i = 0; i < sel.rangeCount; i++) { try { var insideCode, node; range = sel.getRangeAt(i); if (range.compareBoundaryPoints(Range.START_TO_START, postRange) < 0) { range.setStartBefore(root); } if (range.compareBoundaryPoints(Range.END_TO_END, postRange) > 0) { range.setEndAfter(root); } if (!range.toString().trim()) { continue; } var frag = range.cloneContents(); var ancestor = range.commonAncestorContainer; if ($.x('ancestor-or-self::*[self::s or contains(@class,"removed-spoiler")]', ancestor)) { $.prepend(frag, $.tn('[spoiler]')); $.add(frag, $.tn('[/spoiler]')); } if (insideCode = $.x('ancestor-or-self::pre[contains(@class,"prettyprint")]', ancestor)) { $.prepend(frag, $.tn('[code]')); $.add(frag, $.tn('[/code]')); } for (node of $$((insideCode ? 'br' : '.prettyprint br'), frag)) { $.replace(node, $.tn('\n')); } for (node of $$('br', frag)) { if (node !== frag.lastChild) { $.replace(node, $.tn('\n>')); } } g.SITE.insertTags?.(frag); for (node of $$('.linkify[data-original]', frag)) { $.replace(node, $.tn(node.dataset.original)); } for (node of $$('.embedder', frag)) { if (node.previousSibling?.nodeValue === ' ') { $.rm(node.previousSibling); } $.rm(node); } text += `>${frag.textContent.trim()}\n`; } catch (error) { } } QR.openPost(); const { com, thread } = QR.nodes; if (!com.value) { thread.value = Get.threadFromNode(this); } const wasOnlyQuotes = QR.selected.isOnlyQuotes(); const caretPos = com.selectionStart; com.value = com.value.slice(0, caretPos) + text + com.value.slice(com.selectionEnd); range = caretPos + text.length; com.setSelectionRange(range, range); com.focus(); if (wasOnlyQuotes) { QR.selected.quotedText = com.value; } QR.selected.save(com); return QR.selected.save(thread); }, characterCount() { const counter = QR.nodes.charCount; const count = QR.nodes.com.value.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_').length; counter.textContent = count.toString(); counter.hidden = count < (QR.max_comment / 2); const splitPost = QR.nodes.splitPost; if (splitPost) splitPost.hidden = count < QR.max_comment; return (count > QR.max_comment ? $.addClass : $.rmClass)(counter, 'warning'); }, splitPost() { if (QR.selected.isLocked) return; const count = QR.nodes.com.value.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_').length; if (count < QR.max_comment) return; const text = QR.nodes.com.value; let lastPostLength = 0; let splitCount = 0; const idx = QR.posts.indexOf(QR.selected); QR.selected.setComment(""); for (const line of text.split("\n")) { const currentLength = line.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_').length + 1; if (currentLength + lastPostLength > QR.max_comment) { const post = new QR.post(true); post.setComment(line); lastPostLength = currentLength; splitCount++; } else { const newComment = [QR.selected.com, line].filter(el => el !== null).join('\n'); QR.selected.setComment(newComment); lastPostLength += currentLength; } } const newPostIdx = QR.posts.length - splitCount; const newPosts = QR.posts.splice(newPostIdx, splitCount); QR.posts.splice(idx + 1, 0, ...newPosts); const rearrangedDumpList = [...QR.nodes.dumpList.children]; const newDumps = rearrangedDumpList.splice(newPostIdx, splitCount); rearrangedDumpList.splice(idx + 1, 0, ...newDumps); for (const e of rearrangedDumpList) { QR.nodes.dumpList.appendChild(e); } QR.nodes.el.classList.add('dump'); }, getFile() { return $.event('QRFile', QR.selected?.file); }, drawFile(e) { const file = QR.selected?.file; if (!file || !/^(image|video)\//.test(file.type)) { return; } const isVideo = /^video\//.test(file); const el = $.el((isVideo ? 'video' : 'img')); $.on(el, 'error', () => QR.openError()); $.on(el, (isVideo ? 'loadeddata' : 'load'), function () { e.target.getContext('2d').drawImage(el, 0, 0); URL.revokeObjectURL(el.src); return $.event('QRImageDrawn', null, e.target); }); return el.src = URL.createObjectURL(file); }, openError() { const div = $.el('div'); $.extend(div, { innerHTML: 'Could not open file. [More info]' }); return QR.error(div); }, setFile(e) { const { file, name, source } = e.detail; if (name != null) { file.name = name; } if (source != null) { file.source = source; } QR.open(); return QR.handleFiles([file]); }, drag(e) { const toggle = e.type === 'dragstart' ? $.off : $.on; toggle(d, 'dragover', QR.dragOver); return toggle(d, 'drop', QR.dropFile); }, dragOver(e) { e.preventDefault(); return e.dataTransfer.dropEffect = 'copy'; }, dropFile(e) { if (!e.dataTransfer.files.length) { return; } e.preventDefault(); QR.open(); QR.dropTargetPost = QR.findPostFromDropTarget(e.target); QR.isDroppingFiles = true; try { return QR.handleFiles(e.dataTransfer.files); } finally { QR.dropTargetPost = undefined; QR.isDroppingFiles = false; } }, paste(e) { if (!e.clipboardData.items) { return; } let file = null; let score = -1; for (var item of e.clipboardData.items) { var file2; if ((item.kind === 'file') && (file2 = item.getAsFile())) { var score2 = (2 * +(file2.size <= QR.max_size)) + +(file2.type === 'image/png'); if (score2 > score) { file = file2; score = score2; } } } if (file) { const { type } = file; const blob = new Blob([file], { type }); blob.name = `${Conf['pastedname']}.${$.getOwn(QR.extensionFromType, type) || 'jpg'}`; QR.open(); QR.handleFiles([blob]); } }, pasteFF() { const { pasteArea } = QR.nodes; if (!pasteArea.childNodes.length) { return; } const images = $$('img', pasteArea); $.rmAll(pasteArea); for (var img of images) { var m; var { src } = img; if (m = src.match(/data:(image\/(\w+));base64,(.+)/)) { var bstr = atob(m[3]); var arr = new Uint8Array(bstr.length); for (let i = 0; i < bstr.length; i++) { arr[i] = bstr.charCodeAt(i); } var blob = new Blob([arr], { type: m[1] }); blob.name = `${Conf['pastedname']}.${m[2]}`; QR.handleFiles([blob]); } else if (/^https?:\/\//.test(src)) { QR.handleUrl(src); } } }, handleUrl(urlDefault) { QR.open(); const { selected } = QR; selected.preventAutoPost(); CrossOrigin.permission(function () { const url = prompt('Enter a URL:', urlDefault); if (!url) return; QR.nodes.fileButton.focus(); CrossOrigin.file(url, function (blob) { if (blob && !/^text\//.test(blob.type)) { selected.setFile(blob); $.addClass(QR.nodes.el, 'dump'); } else { QR.error("Can't load file."); } }); }); }, handleFiles(files) { if (this !== QR) { files = Array.from(this.files); this.value = null; } else { files = Array.from(files); } if (!files.length) { return; } QR.cleanNotifications(); QR.warnHeavyBatch(files); for (const file of files) { QR.queueFile(file, files.length); } QR.processPendingFiles(); $.addClass(QR.nodes.el, 'dump'); if ((d.activeElement === QR.nodes.fileButton) && $.hasClass(QR.nodes.fileSubmit, 'has-file')) { return QR.nodes.filename.focus(); } }, queueFile(file, nfiles) { const post = QR.pickPostForFile(file, nfiles); const isText = /^text\//.test(file.type); if (!post) { return; } if (isText) { post.pasting = true; } else { post.pendingFile = true; } QR.pendingFiles.push({ file, post, isText }); }, pickPostForFile(file, nfiles) { let post; const isText = /^text\//.test(file.type); if (nfiles === 1) { if (QR.isDroppingFiles) { const target = QR.dropTargetPost; if (target && QR.postCanTakeFile(target, isText)) { post = target; } else if (target) { post = new QR.post(); } else if (QR.postCanTakeFile(QR.selected, isText)) { post = QR.selected; } else { post = new QR.post(); } } else { post = QR.selected; } } else { post = QR.posts[QR.posts.length - 1]; if (!post) { post = new QR.post(); } if (isText ? post.com || post.pasting : post.file || post.pendingFile) { post = new QR.post(); } } return post; }, postCanTakeFile(post, isText) { if (!post) { return false; } return isText ? !(post.com || post.pasting) : !(post.file || post.pendingFile); }, findPostFromDropTarget(target) { let node = target; while (node?.parentNode && !(node instanceof HTMLAnchorElement && $.hasClass(node, 'qr-preview'))) { node = node.parentNode; } if (!(node instanceof HTMLAnchorElement) || !$.hasClass(node, 'qr-preview')) { return; } return QR.posts.find(post => post.nodes.el === node); }, async processPendingFiles() { if (QR.isProcessingPendingFiles) { return; } QR.isProcessingPendingFiles = true; try { while (QR.pendingFiles.length) { const batch = QR.pendingFiles.splice(0, QR.fileBatchSize); await Promise.all(batch.map(({ file, post, isText }) => (isText ? Promise.resolve(post.pasteText(file)) : post.setFile(file)))); } } finally { QR.isProcessingPendingFiles = false; } }, noteAudioStripped() { if (QR.audioStripNotice && !QR.audioStripNotice.closed) { QR.audioStrippedCount++; const msg = QR.audioStripNotice.el.querySelector('.message'); if (msg) { msg.textContent = QR.audioStripMessage(); } QR.audioStripNotice.resetTimer(); return; } QR.audioStrippedCount = 1; QR.audioStripNotice = new Notice('info', QR.audioStripMessage(), 4, () => { QR.audioStripNotice = null; QR.audioStrippedCount = 0; }); }, audioStripMessage() { return QR.audioStrippedCount === 1 ? 'Removed audio from video for this board.' : `Removed audio from ${QR.audioStrippedCount} videos for this board.`; }, warnHeavyBatch(files) { const fileList = Array.from(files); const mediaFiles = fileList.filter(file => /^(image|video)\//.test(file.type)); if (!mediaFiles.length) { return; } const totalSize = mediaFiles.reduce((size, file) => size + file.size, 0); if ((mediaFiles.length < QR.heavyBatchFileCount) && (totalSize < QR.heavyBatchSize)) { return; } const sizeLabel = $.bytesToString(totalSize); new Notice('warning', `Large media batch queued (${mediaFiles.length} files, ${sizeLabel}). Files will be processed in batches of ${QR.fileBatchSize}.`, 8); }, openFileInput() { if (QR.nodes.fileButton.disabled) { return; } QR.nodes.fileInput.click(); return QR.nodes.fileButton.focus(); }, generatePostableThreadsList() { if (!QR.nodes) { return; } const list = QR.nodes.thread; const options = [list.firstElementChild]; for (var thread of g.BOARD.threads.keys) { options.push($.el('option', { value: thread, textContent: `Thread ${thread}` })); } const val = list.value; $.rmAll(list); $.add(list, options); list.value = val; if (list.value === val) { return; } list.value = String(g.VIEW === 'thread' ? g.THREADID : 'new'); if (QR.selected && list) { QR.selected.save(list, true); } QR.refreshCommentPreview(); return (g.VIEW === 'thread' ? $.addClass : $.rmClass)(QR.nodes.el, 'reply-to-thread'); }, dialog() { let dialog, event, nodes; let name; QR.nodes = (nodes = { el: (dialog = UI.dialog('qr', { innerHTML: QuickReplyPage })) }); const setNode = (name, query) => nodes[name] = $(query, dialog); setNode('move', '.move'); setNode('autohide', '#autohide'); setNode('previewToggle', '#qr-preview-toggle'); setNode('draftsButton', '#qr-drafts-button'); setNode('draftsBadge', '#qr-drafts-badge'); setNode('draftsPanel', '#qr-drafts-panel'); setNode('close', '.close'); setNode('thread', 'select'); setNode('form', 'form'); setNode('sjisToggle', '#sjis-toggle'); setNode('texButton', '#tex-preview-button'); setNode('name', '[data-name=name]'); setNode('email', '[data-name=email]'); setNode('sub', '[data-name=sub]'); setNode('com', '[data-name=com]'); setNode('charCount', '#char-count'); setNode('texPreview', '#tex-preview'); setNode('comPreview', '#qr-com-preview'); setNode('dumpList', '#dump-list'); setNode('addPost', '#add-post'); setNode('oekaki', '.oekaki'); setNode('drawButton', '#qr-draw-button'); setNode('randomizeButton', '#qr-randomize'); setNode('compress', '#qr-jpg'); setNode('view', '#qr-view'); setNode('restoreNameButton', '#qr-restore-name'); setNode('fileSubmit', '#file-n-submit'); setNode('fileButton', '#qr-file-button'); setNode('noFile', '#qr-no-file'); setNode('filename', '#qr-filename'); setNode('spoiler', '#qr-file-spoiler'); setNode('oekakiButton', '#qr-oekaki-button'); setNode('dogiriButton', '#qr-dogiri-button'); setNode('fileRM', '#qr-filerm'); setNode('urlButton', '#url-button'); setNode('pasteArea', '#paste-area'); setNode('customCooldown', '#custom-cooldown-button'); setNode('dumpButton', '#dump-button'); setNode('status', '[type=submit]'); setNode('flashTag', '[name=filetag]'); setNode('fileInput', '[type=file]'); setNode('splitPost', '#split-post'); const { config } = g.BOARD; const { classList } = QR.nodes.el; classList.toggle('forced-anon', QR.forcedAnon); classList.toggle('has-spoiler', QR.spoiler); classList.toggle('has-sjis', !!config.sjis_tags); classList.toggle('has-math', !!config.math_tags); classList.toggle('sjis-preview', !!config.sjis_tags && Conf['sjisPreview']); classList.toggle('show-new-thread-option', Conf['Show New Thread Option in Threads']); Icon.set(nodes.previewToggle, 'eye'); QR.applyCommentPreviewSettings(); if (parseInt(Conf['customCooldown'], 10) > 0) { $.addClass(QR.nodes.fileSubmit, 'custom-cooldown'); $.get('customCooldownEnabled', Conf['customCooldownEnabled'], function ({ customCooldownEnabled }) { QR.setCustomCooldown(customCooldownEnabled); return $.sync('customCooldownEnabled', QR.setCustomCooldown); }); } QR.flagsInput(); nodes.name.maxLength = QR.max_name; nodes.email.maxLength = QR.max_email; nodes.sub.maxLength = QR.max_sub; nodes.com.maxLength = QR.max_comment; QR.disablePersonaFieldAutofill(); $.on(nodes.autohide, 'change', QR.toggleHide); $.on(nodes.close, 'click', QR.close); $.on(nodes.status, 'click', QR.submit); $.on(nodes.form, 'submit', QR.submit); $.on(nodes.previewToggle, 'click', QR.toggleCommentPreview); $.on(nodes.sjisToggle, 'click', QR.toggleSJIS); $.on(nodes.texButton, 'mousedown', QR.texPreviewShow); $.on(nodes.texButton, 'mouseup', QR.texPreviewHide); $.on(nodes.addPost, 'click', () => new QR.post(true)); $.on(nodes.drawButton, 'click', QR.oekaki.draw); $.on(nodes.fileButton, 'click', QR.openFileInput); $.on(nodes.noFile, 'click', QR.openFileInput); $.on(nodes.randomizeButton, 'click', e => { QR.selected.randomizeName(); QR.blurMouseFocusedAction(e); }); $.on(nodes.compress, 'click', async (e) => { const action = e.currentTarget; QR.blurPointerFocusedElement(action, e.detail); if (!QR.selected.file) { return; } QR.handleFiles([await QR.convert(QR.selected.file)]); }); $.on(nodes.view, 'click', QR.preview); $.on(nodes.restoreNameButton, 'click', e => { QR.selected.restoreName(); QR.blurMouseFocusedAction(e); }); $.on(nodes.filename, 'focus', function () { return $.addClass(this.parentNode, 'focus'); }); $.on(nodes.filename, 'blur', function () { return $.rmClass(this.parentNode, 'focus'); }); $.on(nodes.spoiler, 'change', () => QR.selected.nodes.spoiler.click()); $.on(nodes.oekakiButton, 'click', e => { QR.oekaki.button(); QR.blurMouseFocusedAction(e); }); $.on(nodes.dogiriButton, 'click', e => { const selected = QR.selected; if (selected?.file) { Dogiri.open(selected.file, { audioStripped: selected.audioWasStripped, originalFile: selected.preStripFile, maxSize: QR.getMaxSize(selected.file), onExportDone: (exported, keepOriginal) => { if (keepOriginal) { $.addClass(QR.nodes.el, 'dump'); new QR.post(true).setFile(exported); return; } selected.dismissErrors(error => $.hasClass(error, 'file-error')); selected.setFile(exported); }, }); } QR.blurMouseFocusedAction(e); }); $.on(nodes.fileRM, 'click', () => QR.selected.rmFile()); $.on(nodes.urlButton, 'click', () => QR.handleUrl('')); $.on(nodes.customCooldown, 'click', QR.toggleCustomCooldown); $.on(nodes.dumpButton, 'click', QR.toggleDumpList); $.on(nodes.fileInput, 'change', QR.handleFiles); $.on(nodes.splitPost, 'click', QR.splitPost); $.on(dialog, 'click', e => { const anchor = e.target?.closest?.('a[href^="javascript:"]'); if (anchor) { e.preventDefault(); } }); window.addEventListener('focus', QR.focus, true); window.addEventListener('blur', QR.focus, true); $.on(d, 'click', QR.focus); if (($.engine === 'gecko') && !window.DataTransferItemList) { nodes.pasteArea.hidden = false; } new MutationObserver(QR.pasteFF).observe(nodes.pasteArea, { childList: true }); const items = ['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag']; let i = 0; const save = function () { QR.selected.save(this); QR.drafts.save(); }; while ((name = items[i++])) { var node; if (!(node = nodes[name])) { continue; } event = node.nodeName === 'SELECT' ? 'change' : 'input'; $.on(nodes[name], event, save); } $.on(nodes.draftsButton, 'click', QR.drafts.togglePanel); QR.drafts.updateButton(); $.on(d, 'QRStateChanged', QR.drafts.onSettingChanged); $.sync('QR Drafts', QR.drafts.onSettingChanged); if (Conf['Remember QR Size']) { $.get('QR Size', '', item => nodes.com.style.cssText = item['QR Size']); $.on(nodes.com, 'mouseup', function (e) { if (e.button !== 0) { return; } $.set('QR Size', this.style.cssText); }); } $.on(nodes.com, 'input', QR.onAutoCloseTag); QR.generatePostableThreadsList(); QR.persona.load(); new QR.post(true); QR.drafts.restore(); QR.status(); QR.cooldown.setup(); QR.captcha.init(); if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { QRMobile.setup(nodes); } $.add(d.body, dialog); QR.captcha.setup(); QR.oekaki.setup(); if (typeof ResizeObserver === 'function' && !QR._qrResizeObs) { QR._qrResizeObs = new ResizeObserver(() => QR.repositionFloatingPreview()); QR._qrResizeObs.observe(dialog); } $.event('QRDialogCreation', null, dialog); Icon.set(nodes.oekakiButton, 'pencil'); Icon.set(nodes.dogiriButton, 'film'); nodes.dogiriButton.hidden = !Conf['Dogiri Editor']; Icon.set(nodes.urlButton, 'link'); Icon.set(nodes.pasteArea, 'clipboard'); Icon.set(nodes.customCooldown, 'clock'); Icon.set(nodes.randomizeButton, 'shuffle'); Icon.set(nodes.compress, 'shrink'); Icon.set(nodes.view, 'eye'); Icon.set(nodes.restoreNameButton, 'undo'); if (nodes.splitPost) Icon.set(nodes.splitPost, 'scissors'); Icon.set(nodes.fileRM, 'xmark'); Icon.set(nodes.close, 'xmark'); Icon.set(nodes.dumpButton, 'squarePlus'); Icon.set(nodes.addPost, 'plus'); }, flags() { const select = $.el('select', { name: 'flag', className: 'flagSelector' }); const picker = $.el('div', { className: 'flagSelector-picker' }); const toggle = $.el('button', { type: 'button', className: 'flagSelector-toggle field' }); toggle.setAttribute('aria-expanded', 'false'); const icon = $.el('span', { className: 'flagSelector-icon flagSelector-icon-empty' }); icon.setAttribute('aria-hidden', 'true'); const label = $.el('span', { className: 'flagSelector-label' }); $.add(toggle, [icon, label]); $.add(picker, toggle); const menu = $.el('div', { className: 'flagSelector-menu', hidden: true }); const entries = {}; let open = false; const closeMenu = () => { if (!open) return; open = false; picker.classList.remove('open'); toggle.setAttribute('aria-expanded', 'false'); menu.hidden = true; }; const updateMenuPosition = () => { const rect = toggle.getBoundingClientRect(); const width = Math.max(rect.width, 180); const pad = 8; const left = Math.min(Math.max(rect.left, pad), Math.max(pad, window.innerWidth - width - pad)); const spaceBelow = window.innerHeight - rect.bottom - pad; const spaceAbove = rect.top - pad; const desired = Math.min(menu.scrollHeight || 320, 320); const openUpward = spaceBelow < 150 && spaceAbove > spaceBelow; const maxHeight = Math.max(100, openUpward ? spaceAbove : spaceBelow); const height = Math.min(desired, maxHeight); const top = openUpward ? rect.top - height : rect.bottom; menu.style.left = `${left}px`; menu.style.top = `${Math.max(pad, top)}px`; menu.style.width = `${width}px`; menu.style.maxHeight = `${maxHeight}px`; }; const isOpaque = (color) => { const m = color.match(/[\d.]+/g); return !!m && (m.length < 4 || parseFloat(m[3]) > 0.01); }; const syncTheme = () => { let fg = ''; let bg = ''; let border = ''; const sibling = QR.nodes?.name; if (sibling) { const cs = window.getComputedStyle(sibling); fg = cs.color; border = cs.borderTopColor; if (isOpaque(cs.backgroundColor)) { bg = cs.backgroundColor; } } if (!fg) { const formEl = (QR.nodes?.form || picker.parentElement); if (formEl) { fg = window.getComputedStyle(formEl).color; } } if (!bg) { let el = toggle; while (el) { const cs = window.getComputedStyle(el); if (isOpaque(cs.backgroundColor)) { bg = cs.backgroundColor; break; } el = el.parentElement; } } const root = d.documentElement; if (bg && root.style.getPropertyValue('--xt-qr-sampled-bg') !== bg) { root.style.setProperty('--xt-qr-sampled-bg', bg); } if (fg && root.style.getPropertyValue('--xt-qr-sampled-fg') !== fg) { root.style.setProperty('--xt-qr-sampled-fg', fg); } if (border && root.style.getPropertyValue('--xt-qr-sampled-border') !== border) { root.style.setProperty('--xt-qr-sampled-border', border); } }; let themeSyncRaf = 0; const scheduleThemeSync = () => { if (themeSyncRaf) return; themeSyncRaf = requestAnimationFrame(() => { themeSyncRaf = 0; syncTheme(); }); }; const themeObserver = new MutationObserver(scheduleThemeSync); themeObserver.observe(d.head, { childList: true, subtree: true, characterData: true }); themeObserver.observe(d.documentElement, { attributes: true, attributeFilter: ['class'] }); themeObserver.observe(d.body, { attributes: true, attributeFilter: ['class'] }); const openMenu = () => { if (open) return; open = true; picker.classList.add('open'); toggle.setAttribute('aria-expanded', 'true'); menu.hidden = false; syncTheme(); updateMenuPosition(); }; const syncSelected = () => { let value = '' + (select.value || ''); if (!(value in entries)) { value = '' + (select.options[0]?.value || '0'); select.value = value; } const data = entries[value]; icon.className = `flagSelector-icon ${data.iconClass}`.trim(); if (!data.iconClass) icon.classList.add('flagSelector-icon-empty'); label.textContent = data.text; for (const opt of Array.from(menu.querySelectorAll('.flagSelector-option'))) { opt.classList.toggle('selected', opt.dataset.value === value); } }; const addFlag = (value, textContent) => { $.add(select, $.el('option', { value, textContent })); const valueStr = '' + value; const code = valueStr.toLowerCase().replace(/[^a-z0-9_-]+/g, '-'); const iconClass = valueStr !== '0' ? `bfl bfl-${code}` : ''; entries[valueStr] = { text: textContent, iconClass }; const option = $.el('button', { type: 'button', className: 'flagSelector-option' }); option.dataset.value = valueStr; const optionIcon = $.el('span', { className: `flagSelector-icon ${iconClass}`.trim() }); if (!iconClass) optionIcon.classList.add('flagSelector-icon-empty'); optionIcon.setAttribute('aria-hidden', 'true'); const optionLabel = $.el('span', { className: 'flagSelector-label', textContent }); $.add(option, [optionIcon, optionLabel]); $.on(option, 'click', () => { if (select.disabled) return; select.value = valueStr; select.dispatchEvent(new Event('change', { bubbles: true })); closeMenu(); }); $.add(menu, option); }; addFlag('0', g.BOARD.config.country_flags ? 'Geographic Location' : 'None'); for (const value in g.BOARD.config.board_flags) { addFlag(value, g.BOARD.config.board_flags[value]); } const onToggleClick = (e) => { e.preventDefault(); e.stopPropagation(); if (select.disabled) return; if (open) closeMenu(); else openMenu(); }; const onOutsideMouseDown = (e) => { if (!open) return; const target = e.target; if (picker.contains(target) || menu.contains(target)) return; closeMenu(); }; const onDocKeydown = (e) => { if (!open) return; if (e.key === 'Escape') { closeMenu(); toggle.focus(); } }; const onViewportChange = () => { if (open) closeMenu(); }; $.on(toggle, 'click', onToggleClick); $.on(d, 'mousedown', onOutsideMouseDown); $.on(d, 'keydown', onDocKeydown); $.on(window, 'resize', onViewportChange); $.on(window, 'scroll', onViewportChange); $.on(QR.nodes.form, 'scroll', onViewportChange); $.on(QR.nodes.form, 'focusin', syncTheme); $.on(select, 'change', syncSelected); new MutationObserver(() => { toggle.disabled = select.disabled; if (select.disabled) closeMenu(); }).observe(select, { attributes: true, attributeFilter: ['disabled'] }); select._syncFlagPicker = syncSelected; select._destroyFlagPicker = () => { closeMenu(); $.off(toggle, 'click', onToggleClick); $.off(d, 'mousedown', onOutsideMouseDown); $.off(d, 'keydown', onDocKeydown); $.off(window, 'resize', onViewportChange); $.off(window, 'scroll', onViewportChange); $.off(QR.nodes.form, 'scroll', onViewportChange); $.off(QR.nodes.form, 'focusin', syncTheme); $.off(select, 'change', syncSelected); themeObserver.disconnect(); if (themeSyncRaf) { cancelAnimationFrame(themeSyncRaf); themeSyncRaf = 0; } $.rm(menu); }; $.add(d.body, menu); syncSelected(); setTimeout(syncTheme, 0); return { select, picker }; }, flagsInput() { const { nodes } = QR; if (!nodes) { return; } if (nodes.flag) { nodes.flag._destroyFlagPicker?.(); const picker = nodes.flag._picker; if (picker) $.rm(picker); $.rm(nodes.flag); delete nodes.flag; } if (!g.BOARD.config.board_flags) return; const { select, picker } = QR.flags(); select.dataset.name = 'flag'; select.dataset.default = '0'; nodes.flag = select; nodes.flag._picker = picker; $.add(nodes.form, select); $.add(nodes.form, picker); }, updateFlagSelector() { QR.nodes?.flag?._syncFlagPicker?.(); }, disablePersonaFieldAutofill() { if (!QR.nodes) { return; } if (Conf['Allow Browser Autofill']) { QR.nodes.form.removeAttribute('autocomplete'); const enabledNames = { name: 'name', email: 'email', sub: 'sub', }; for (const key of ['name', 'email', 'sub']) { const input = QR.nodes[key]; if (!input) { continue; } input.name = enabledNames[key]; input.readOnly = false; input.removeAttribute('data-no-autofill'); input.setAttribute('autocomplete', 'on'); input.removeAttribute('aria-autocomplete'); input.removeAttribute('autocapitalize'); input.removeAttribute('autocorrect'); input.removeAttribute('spellcheck'); for (const attr of [ 'data-lpignore', 'data-1p-ignore', 'data-bwignore', 'data-protonpass-ignore', 'data-form-type', ]) { input.removeAttribute(attr); } } return; } QR.nodes.form.setAttribute('autocomplete', 'off'); const names = { name: 'qr-no-autofill-name', email: 'qr-no-autofill-options', sub: 'qr-no-autofill-subject', }; for (const key of ['name', 'email', 'sub']) { const input = QR.nodes[key]; if (!input) { continue; } input.name = names[key]; input.removeAttribute('list'); input.setAttribute('autocomplete', 'off'); input.setAttribute('autocapitalize', 'off'); input.setAttribute('autocorrect', 'off'); input.setAttribute('spellcheck', 'false'); input.setAttribute('aria-autocomplete', 'none'); input.setAttribute('data-lpignore', 'true'); input.setAttribute('data-1p-ignore', 'true'); input.setAttribute('data-bwignore', 'true'); input.setAttribute('data-protonpass-ignore', 'true'); input.setAttribute('data-form-type', 'other'); input.readOnly = false; } }, submit(e) { let captcha, err, filetag; e?.preventDefault(); const force = e?.shiftKey; if (QR.req) { QR.abort(); return; } $.forceSync('cooldowns'); if (QR.cooldown.seconds) { if (force) { QR.cooldown.clear(); } else { QR.cooldown.auto = !QR.cooldown.auto; QR.status(); return; } } const post = QR.posts[0]; delete post.quotedText; post.forceSave(); const board = g.BOARD; let threadID = post.thread; const thread = board.threads.get(threadID); if ((board.ID === 'f') && (threadID === 'new')) { filetag = QR.nodes.flashTag.value; } if (threadID === 'new') { threadID = null; if (!!board.config.require_subject && !post.sub) { err = 'New threads require a subject.'; } else if (!!!board.config.text_only && !post.file) { err = 'No file selected.'; } } else if (board.threads.get(threadID).isClosed) { err = 'You can\'t reply to this thread anymore.'; } else if (!post.com && !post.file) { err = 'No comment or file.'; } else if (post.file && thread.fileLimit) { err = 'Max limit of image replies has been reached.'; } if (!err && post.file) { const maxSize = QR.getMaxSize(post.file); if (post.file.size > maxSize) { err = `File too large (file: ${$.bytesToString(post.file.size)}, max: ${$.bytesToString(maxSize)}).`; } } if ((board.ID === 'r9k') && !post.com?.match(/[a-z-]/i)) { if (!err) { err = 'Original comment required.'; } } const unitLength = (str) => (str || '').replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_').length; if (!err && !QR.forcedAnon && unitLength(post.name) > QR.max_name) { err = `Name is too long (${unitLength(post.name)}/${QR.max_name}).`; } if (!err && unitLength(post.email) > QR.max_email) { err = `Options is too long (${unitLength(post.email)}/${QR.max_email}).`; } if (!err && unitLength(post.sub) > QR.max_sub) { err = `Subject is too long (${unitLength(post.sub)}/${QR.max_sub}).`; } if (!err && unitLength(post.com) > QR.max_comment) { err = `Comment is too long (${unitLength(post.com)}/${QR.max_comment}).`; } if (QR.captcha.isEnabled && !((QR.captcha === Captcha.v2) && /\b_ct=/.test(d.cookie) && threadID) && !(err && !force)) { captcha = QR.captcha.getOne(!!threadID); if (QR.captcha === Captcha.v2) { if (!captcha) { captcha = Captcha.cache.request(!!threadID); } } if (!captcha) { if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice()) && QR.captcha === Captcha.t) { QR.captcha.pendingSubmitIntent = true; err = 'Solve the captcha to post.'; } else { err = 'No valid captcha.'; } QR.captcha.setup(!QR.cooldown.auto || (d.activeElement === QR.nodes.status)); } } QR.cleanNotifications(); if (err && !force) { QR.cooldown.auto = false; QR.status(); QR.error(err); return; } QR.cooldown.auto = QR.posts.length > 1; post.lock(); const formData = { MAX_FILE_SIZE: QR.max_size, mode: 'regist', pwd: QR.persona.getPassword(), resto: threadID, name: (!QR.forcedAnon ? post.name : undefined), email: post.email, sub: (!QR.forcedAnon && !threadID ? post.sub : undefined), com: post.com, upfile: post.file, filetag, spoiler: post.spoiler, flag: post.flag, }; const options = { responseType: 'document', withCredentials: true, onloadend: QR.response, form: $.formData(formData) }; if (Conf['Show Upload Progress']) { options.onprogress = function (e) { const req = QR.req; if (this !== req?.upload || !req) { return; } if (e.loaded < e.total) { req.progress = `${Math.round((e.loaded / e.total) * 100)}%`; } else { req.isUploadFinished = true; req.progress = '...'; } return QR.status(); }; } let cb = function (response) { if (response != null) { QR.currentCaptcha = response; if (QR.captcha === Captcha.v2) { if (response.challenge != null) { options.form.append('recaptcha_challenge_field', response.challenge); options.form.append('recaptcha_response_field', response.response); } else { options.form.append('g-recaptcha-response', response.response); } } else { for (var key in response) { var val = response[key]; options.form.append(key, val); } } } QR.req = $.ajax(`https://sys.${location.hostname.split('.')[1]}.org/${g.BOARD}/post`, options); QR.req.progress = '...'; }; if (typeof captcha === 'function') { QR.req = { progress: '...', abort() { if (QR.captcha === Captcha.v2) { Captcha.cache.abort(); } cb = null; } }; captcha(function (response) { if ((QR.captcha === Captcha.v2) && Captcha.cache.haveCookie()) { cb?.(); if (response) { return Captcha.cache.save(response); } } else if (response) { cb?.(response); } else { delete QR.req; post.unlock(); QR.cooldown.auto = !!Captcha.cache.getCount(); QR.status(); } }); } else { cb?.(captcha); } QR.status(); }, response() { let connErr, err; if (this !== QR.req) { return; } delete QR.req; const submittedCaptcha = QR.currentCaptcha; const post = QR.posts[0]; post.unlock(); if (err = this.response?.getElementById('errmsg')) { const el = $('a', err); if (el) el.target = '_blank'; } else if (connErr = (!this.response || (this.response.title !== 'Post successful!'))) { err = QR.connectionError(); if ((QR.captcha === Captcha.v2) && submittedCaptcha) { Captcha.cache.save(submittedCaptcha); } } else if (this.status !== 200) { err = `Error ${this.statusText} (${this.status})`; } if (!connErr) { QR.captcha.setUsed?.(); } delete QR.currentCaptcha; if (err) { let m; QR.errorCount = (QR.errorCount || 0) + 1; if (/captcha|verification/i.test(err.textContent)) { const wasNoopTCaptcha = (QR.captcha === Captcha.t) && !!submittedCaptcha?.['t-challenge'] && !submittedCaptcha?.['t-response']; if (wasNoopTCaptcha) { QR.captcha.setState?.('failed'); QR.captcha.forceLoad?.(); err = 'Captcha is now required. A new captcha has been requested.'; } else { if (/mistyped/i.test(err.textContent)) { err = 'You mistyped the CAPTCHA, or the CAPTCHA malfunctioned.'; QR.captcha.setState?.('failed'); } else if (/expired/i.test(err.textContent)) { err = 'This CAPTCHA is no longer valid because it has expired.'; QR.captcha.setState?.('expired'); } else { QR.captcha.setState?.('failed'); } } QR.cooldown.auto = false; } else if (connErr) { if (QR.errorCount >= 5) { QR.cooldown.auto = false; } else { QR.cooldown.auto = QR.captcha.isEnabled || connErr; QR.cooldown.addDelay(post, 2); } } else if (err.textContent && (m = err.textContent.match(/\d+\s+(?:minute|second)/gi)) && !/duplicate|hour/i.test(err.textContent)) { QR.cooldown.auto = !/have\s+been\s+muted/i.test(err.textContent); let seconds = 0; for (var mi of m) { seconds += (/minute/i.test(mi) ? 60 : 1) * (+mi.match(/\d+/)[0]); } if (/muted/i.test(err.textContent)) { QR.cooldown.addMute(seconds); } else { QR.cooldown.addDelay(post, seconds); } } else { QR.cooldown.auto = false; } QR.captcha.setup(QR.cooldown.auto && [QR.nodes.status, d.body].includes(d.activeElement)); QR.status(); QR.error(err); return; } delete QR.errorCount; const h1 = $('h1', this.response); let [_, threadID, postID] = h1.nextSibling.textContent.match(/thread:(\d+),no:(\d+)/); postID = +postID; threadID = +threadID || postID; const isReply = threadID !== postID; $.event('QRPostSuccessful', { boardID: g.BOARD.ID, threadID, postID }); $.event('QRPostSuccessful_', { boardID: g.BOARD.ID, threadID, postID }); const postsCount = QR.posts.length - 1; QR.cooldown.auto = !!postsCount && isReply; const lastPostToThread = !((function () { for (var p of QR.posts.slice(1)) { if (p.thread === post.thread) { return true; } } })()); if (postsCount) { post.rm(); QR.captcha.setup(d.activeElement === QR.nodes.status); } else if (shouldCloseAfterPost(QRMobile.enabled, $.hasClass(QR.nodes.el, 'dump'), postsCount)) { QR.close(); } else if (Conf['Persistent QR']) { post.rm(); if (Conf['Auto Hide QR']) { QR.hide(); } else { QR.blur(); } } else { QR.close(); } QR.drafts.flush(); QR.cleanNotifications(); if (Conf['Posting Success Notifications']) { QR.notifications.push(new Notice('success', h1.textContent, 5)); } QR.cooldown.add(threadID, postID); const URL = threadID === postID ? ( `${window.location.origin}/${g.BOARD}/thread/${threadID}`) : (threadID !== g.THREADID) && lastPostToThread && Conf['Open Post in New Tab'] ? ( `${window.location.origin}/${g.BOARD}/thread/${threadID}#p${postID}`) : undefined; if (URL) { const open = Conf['Open Post in New Tab'] || postsCount ? () => $.open(URL) : () => location.href = URL; if (threadID === postID) { QR.waitForThread(URL, open); } else { open(); } } QR.status(); }, waitForThread(url, cb) { let attempts = 0; var check = function () { $.ajax(url, { onloadend() { attempts++; if ((attempts >= 6) || (this.status === 200)) { return cb(); } else { return setTimeout(check, attempts * SECOND); } }, responseType: 'text', type: 'HEAD' }); }; check(); }, abort() { let oldReq; if ((oldReq = QR.req) && !QR.req.isUploadFinished) { delete QR.req; oldReq.abort(); if ((QR.captcha === Captcha.v2) && QR.currentCaptcha) { Captcha.cache.save(QR.currentCaptcha); } delete QR.currentCaptcha; QR.posts[0].unlock(); QR.cooldown.auto = false; QR.notifications.push(new Notice('info', 'QR upload aborted.', 5)); } QR.status(); }, getMaxSize(file) { let max = QR.max_size; if (file.type.startsWith('video/')) max = Math.min(max, QR.max_size_video); return max; }, async convert(file, type = 'jpeg', options) { const maxSize = options?.maxSize || this.getMaxSize(file); const img = options?.img || await createImageBitmap(file); const width = options?.width || img.width; const height = options?.height || img.height; const newName = file.name.replace(/\.[a-z]+$/i, '.' + type); const mime = 'image/' + type; let canvas; let toBlob; if (window.OffscreenCanvas && !Conf['Avoid OffscreenCanvas']) { canvas = new OffscreenCanvas(width, height); toBlob = (mime, quality) => canvas.convertToBlob({ type: mime, quality }); } else { canvas = $.el('canvas', { width, height }); toBlob = (mime, quality) => new Promise(resolve => { canvas.toBlob(blob => resolve(blob), mime, quality); }); } let newFile; let quality = .9; canvas.getContext("2d").drawImage(img, 0, 0, width, height); do { newFile = new File([await toBlob(mime, quality)], newName, { type: mime }); quality -= .1; } while (type === 'jpeg' && newFile.size > maxSize && quality >= .1); if (newFile.size >= file.size && newFile.type === file.type) { new Notice('warning', "New jpeg file isn't smaller than the old one, so it won't be used.", 3); return file; } return newFile; }, previewUrl: undefined, preview() { if (!QR.selected.file) return; QR.nodes.preview = $.el('div', { id: 'overlay', className: 'media-preview' }); $.add(d.body, QR.nodes.preview); QR.previewUrl = URL.createObjectURL(QR.selected.file); if (QR.selected.file.type.startsWith('video/')) { const video = $.el('video', { controls: true, src: QR.previewUrl, autoplay: true }); $.add(QR.nodes.preview, video); video.focus(); } else { $.add(QR.nodes.preview, $.el('img', { src: QR.previewUrl })); } QR.nodes.preview.addEventListener('click', (e) => { if (e.target.tagName !== 'VIDEO') QR.closePreview(); }); }, closePreview() { QR.nodes.preview?.remove(); URL.revokeObjectURL(QR.previewUrl); }, cooldown: { seconds: 0, delays: { deletion: 60 }, maxDelay: 0, isSetup: false, auto: false, data: {}, customCooldown: false, isCounting: false, changes: null, timeout: 0, init() { if (!Conf['Quick Reply']) { return; } this.data = Conf['cooldowns']; this.changes = dict(); $.sync('cooldowns', this.sync); }, setup() { $.extend(QR.cooldown.delays, g.BOARD.cooldowns()); QR.cooldown.maxDelay = 0; for (var type in QR.cooldown.delays) { var delay = QR.cooldown.delays[type]; if (!['thread', 'thread_global'].includes(type)) { QR.cooldown.maxDelay = Math.max(QR.cooldown.maxDelay, delay); } } QR.cooldown.isSetup = true; QR.cooldown.start(); }, start() { const { data } = QR.cooldown; if (!Conf['Cooldown'] || !QR.cooldown.isSetup || !!QR.cooldown.isCounting || ((Object.keys(data[g.BOARD.ID] || {}).length + Object.keys(data.global || {}).length) <= 0)) { return; } QR.cooldown.isCounting = true; QR.cooldown.count(); }, sync(data) { QR.cooldown.data = data || dict(); QR.cooldown.start(); }, add(threadID, postID) { if (!Conf['Cooldown']) { return; } const start = Date.now(); const boardID = g.BOARD.ID; QR.cooldown.set(boardID, start, { threadID, postID }); if (threadID === postID) { QR.cooldown.set('global', start, { boardID, threadID, postID }); } QR.cooldown.save(); QR.cooldown.start(); }, addDelay(post, delay) { if (!Conf['Cooldown']) { return; } const cooldown = QR.cooldown.categorize(post); cooldown.delay = delay; QR.cooldown.set(g.BOARD.ID, Date.now(), cooldown); QR.cooldown.save(); QR.cooldown.start(); }, addMute(delay) { if (!Conf['Cooldown']) { return; } QR.cooldown.set(g.BOARD.ID, Date.now(), { type: 'mute', delay }); QR.cooldown.save(); QR.cooldown.start(); }, delete(post) { let cooldown; if (!QR.cooldown.data) { return; } const cooldowns = (QR.cooldown.data[post.board.ID] || (QR.cooldown.data[post.board.ID] = dict())); for (var id in cooldowns) { cooldown = cooldowns[id]; if ((cooldown.delay == null) && (cooldown.threadID === post.thread.ID) && (cooldown.postID === post.ID)) { QR.cooldown.set(post.board.ID, id, null); } } QR.cooldown.save(); }, secondsDeletion(post) { if (!QR.cooldown.data || !Conf['Cooldown']) { return 0; } const cooldowns = QR.cooldown.data[post.board.ID] || dict(); for (var start in cooldowns) { var cooldown = cooldowns[start]; if ((cooldown.delay == null) && (cooldown.threadID === post.thread.ID) && (cooldown.postID === post.ID)) { var seconds = QR.cooldown.delays.deletion - Math.floor((Date.now() - +start) / SECOND); return Math.max(seconds, 0); } } return 0; }, categorize(post) { if (post.thread === 'new') { return { type: 'thread' }; } else { return { type: !!post.file ? 'image' : 'reply', threadID: +post.thread }; } }, mergeChange(data, scope, id, value) { if (value) { (data[scope] || (data[scope] = dict()))[id] = value; } else if (scope in data) { delete data[scope][id]; if (Object.keys(data[scope]).length === 0) delete data[scope]; } }, set(scope, id, value) { QR.cooldown.mergeChange(QR.cooldown.data, scope, id, value); (QR.cooldown.changes[scope] || (QR.cooldown.changes[scope] = dict()))[id] = value; }, save() { const { changes } = QR.cooldown; if (!Object.keys(changes).length) { return; } $.get('cooldowns', dict(), function ({ cooldowns }) { for (var scope in QR.cooldown.changes) { for (var id in QR.cooldown.changes[scope]) { var value = QR.cooldown.changes[scope][id]; QR.cooldown.mergeChange(cooldowns, scope, id, value); } QR.cooldown.data = cooldowns; } $.set('cooldowns', cooldowns, () => QR.cooldown.changes = dict()); }); }, clear() { QR.cooldown.data = dict(); QR.cooldown.changes = dict(); QR.cooldown.auto = false; QR.cooldown.update(); $.queueTask($.delete, 'cooldowns'); }, update() { let cooldown; if (!QR.cooldown.isCounting) { return; } let save = false; let nCooldowns = 0; const now = Date.now(); const { type, threadID } = QR.cooldown.categorize(QR.posts[0]); let seconds = 0; if (Conf['Cooldown']) { for (var scope of [g.BOARD.ID, 'global']) { var cooldowns = (QR.cooldown.data[scope] || (QR.cooldown.data[scope] = dict())); for (var start in cooldowns) { cooldown = cooldowns[start]; start = +start; var elapsed = Math.floor((now - start) / SECOND); if (elapsed < 0) { QR.cooldown.set(scope, start, null); save = true; continue; } if (cooldown.delay != null) { if (cooldown.delay <= elapsed) { QR.cooldown.set(scope, start, null); save = true; } else if (((cooldown.type === type) && (cooldown.threadID === threadID)) || (cooldown.type === 'mute')) { seconds = Math.max(seconds, cooldown.delay - elapsed); } continue; } var maxDelay = cooldown.threadID !== cooldown.postID ? QR.cooldown.maxDelay : QR.cooldown.delays[scope === 'global' ? 'thread_global' : 'thread']; if (QR.cooldown.customCooldown) { maxDelay = Math.max(maxDelay, parseInt(Conf['customCooldown'], 10)); } if (maxDelay <= elapsed) { QR.cooldown.set(scope, start, null); save = true; continue; } if (((type === 'thread') === (cooldown.threadID === cooldown.postID)) && (cooldown.boardID !== g.BOARD.ID)) { var suffix = scope === 'global' ? '_global' : ''; seconds = Math.max(seconds, QR.cooldown.delays[type + suffix] - elapsed); if (QR.cooldown.customCooldown) { seconds = Math.max(seconds, parseInt(Conf['customCooldown'], 10) - elapsed); } } } nCooldowns += Object.keys(cooldowns).length; } } if (save) { QR.cooldown.save; } if (nCooldowns) { clearTimeout(QR.cooldown.timeout); QR.cooldown.timeout = window.setTimeout(QR.cooldown.count, SECOND); } else { delete QR.cooldown.isCounting; } const update = seconds !== QR.cooldown.seconds; QR.cooldown.seconds = seconds; if (update) QR.status(); }, count() { QR.cooldown.update(); if ((QR.cooldown.seconds === 0) && QR.cooldown.auto && !QR.req) QR.submit(); } }, oekaki: { loadPromise: null, loadFailed: false, pageWindow() { return window.wrappedJSObject || (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); }, getTegaki() { const page = QR.oekaki.pageWindow(); return page?.Tegaki || window.Tegaki || globalThis.Tegaki; }, async loadUserscriptTegaki() { if (QR.oekaki.getTegaki()) { return true; } if (QR.oekaki.loadFailed) { return false; } if (QR.oekaki.loadPromise) { return QR.oekaki.loadPromise; } QR.oekaki.loadPromise = (async () => { try { if (typeof GM_addElement !== 'function') { throw new Error('GM_addElement is unavailable; cannot bypass site CSP to load Tegaki.'); } const cacheBust = Date.now(); const style = GM_addElement(d.head, 'link', { rel: 'stylesheet', href: `https://s.4cdn.org/css/tegaki.${cacheBust}.css` }); const script = GM_addElement(d.head, 'script', { src: `https://s.4cdn.org/js/tegaki.min.${cacheBust}.js` }); if (!script) { throw new Error('GM_addElement did not return a script element.'); } await new Promise((resolve, reject) => { let pending = style ? 2 : 1; const done = () => { if (--pending === 0) resolve(); }; $.on(script, 'load', done); $.on(script, 'error', () => reject(new Error('Failed to load Tegaki script.'))); if (style) { $.on(style, 'load', done); $.on(style, 'error', done); } }); if (!QR.oekaki.getTegaki()) { throw new Error('Tegaki script loaded but did not expose the Tegaki global.'); } return true; } catch (error) { console.error(error); QR.oekaki.loadFailed = true; QR.error('Failed to load Tegaki from userscript context.'); return false; } finally { QR.oekaki.loadPromise = null; } })(); return QR.oekaki.loadPromise; }, setupDirect() { const page = QR.oekaki.pageWindow(); if (!page) { return false; } page.FCX || (page.FCX = {}); page.FCX.oekakiCB = () => QR.oekaki.getTegaki()?.flatten().toBlob((file) => { const source = `oekaki-${Date.now()}`; page.FCX.oekakiLatest = source; $.event('QRSetFile', { file, name: page.FCX.oekakiName, source }); }); if (QR.oekaki.getTegaki()) { QR.nodes.oekaki.hidden = false; } return true; }, drawDirect() { const page = QR.oekaki.pageWindow(); const Tegaki = QR.oekaki.getTegaki(); const FCX = page?.FCX; if (!(Tegaki && FCX)) { return false; } if (Tegaki.bg) { Tegaki.destroy(); } FCX.oekakiName = 'tegaki.png'; Tegaki.open({ onDone: FCX.oekakiCB, onCancel() { Tegaki.bgColor = '#ffffff'; }, width: +$('#qr [name=oekaki-width]')?.value, height: +$('#qr [name=oekaki-height]')?.value, bgColor: $('#qr [name=oekaki-bg]')?.checked ? $('#qr [name=oekaki-bgcolor]')?.value : 'transparent' }); return true; }, loadDirect() { const page = QR.oekaki.pageWindow(); const Tegaki = QR.oekaki.getTegaki(); const FCX = page?.FCX; if (!(Tegaki && FCX)) { return false; } const name = QR.nodes.filename.value.replace(/\.\w+$/, '') + '.png'; const { source } = QR.nodes.fileSubmit.dataset; const error = (content) => QR.error(content); const cb = function (e) { if (e) { this.removeEventListener('QRMetadata', cb, false); } const selected = QR.selected?.nodes?.el; if (!selected?.dataset.type) return error('No file to edit.'); if (!/^(image|video)\//.test(selected.dataset.type)) { return error('Not an image.'); } if (!selected.dataset.height || !selected.dataset.width) return error('Metadata not available.'); if (selected.dataset.height === 'loading') { selected.addEventListener('QRMetadata', cb, false); return; } const width = +selected.dataset.width; const height = +selected.dataset.height; if (!(width > 0) || !(height > 0)) return error('Metadata not available.'); if (Tegaki.bg) { Tegaki.destroy(); } FCX.oekakiName = name; Tegaki.open({ onDone: FCX.oekakiCB, onCancel() { Tegaki.bgColor = '#ffffff'; }, width, height, bgColor: 'transparent' }); const canvas = $.el('canvas', { width, height, hidden: true }); $.add(d.body, canvas); canvas.addEventListener('QRImageDrawn', function () { this.remove(); canvas.toBlob(blob => { if (!blob) { return error('Could not snapshot image for the editor.'); } const img = $.el('img'); const url = URL.createObjectURL(blob); $.on(img, 'load', () => { URL.revokeObjectURL(url); Tegaki.onOpenImageLoaded.call(img); }); $.on(img, 'error', () => { URL.revokeObjectURL(url); error('Could not load image into the editor.'); }); img.src = url; }); }, false); $.event('QRDrawFile', null, canvas); }; if (Tegaki.bg && (Tegaki.onDoneCb === FCX.oekakiCB) && (source === FCX.oekakiLatest)) { FCX.oekakiName = name; Tegaki.resume(); } else { cb(); } return true; }, menu: { post: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu'] || !Conf['Edit Link'] || !Conf['Quick Reply']) { return; } const a = $.el('a', { className: 'edit-link', href: 'javascript:;', textContent: 'Edit image' }); $.on(a, 'click', this.editFile); Menu.menu.addEntry({ el: a, order: 90, open(post) { QR.oekaki.menu.post = post; const { file } = post; return QR.postingIsEnabled && !!file && (file.isImage || file.isVideo); } }); }, preparePost(post) { QR.openPost(); if (!QR.postCanTakeFile(QR.selected, false)) { new QR.post(true); $.addClass(QR.nodes.el, 'dump'); } QR.quote.call(post.nodes.post, undefined); return QR.selected; }, editSelectedPost(post) { post.select(); const { el } = post.nodes; const open = function (e) { if (e) { el.removeEventListener('QRMetadata', open, false); } if (!el.dataset.type || !/^(image|video)\//.test(el.dataset.type)) { return; } if (el.dataset.height === 'loading') { el.addEventListener('QRMetadata', open, false); return; } if (!el.dataset.height || !el.dataset.width) { return; } post.select(); QR.oekaki.edit(); }; open(); }, editFile(e) { e?.preventDefault(); const { post } = QR.oekaki.menu; const target = QR.oekaki.menu.preparePost(post); if (!target) { return; } const { isVideo } = post.file; const currentTime = post.file.fullImage?.currentTime || 0; return CrossOrigin.file(post.file.url, function (blob) { if (!blob) { QR.error("Can't load file."); } else if (isVideo) { const video = $.el('video'); $.on(video, 'loadedmetadata', function () { $.on(video, 'seeked', function () { const canvas = $.el('canvas', { width: video.videoWidth, height: video.videoHeight }); canvas.getContext('2d').drawImage(video, 0, 0); canvas.toBlob(async function (snapshot) { if (!snapshot) { return QR.error('Could not snapshot video for the editor.'); } const file = new File([snapshot], post.file.name.replace(/\.\w+$/, '') + '.png', { type: 'image/png' }); await target.setFile(file); QR.oekaki.menu.editSelectedPost(target); }); }); video.currentTime = currentTime; }); $.on(video, 'error', () => QR.openError()); video.src = URL.createObjectURL(blob); } else { const file = new File([blob], post.file.name, { type: blob.type }); target.setFile(file).then(() => QR.oekaki.menu.editSelectedPost(target)); } }); } }, setup() { if (platform === 'userscript' && QR.oekaki.setupDirect()) { return; } $.global('setupQR'); }, load(cb) { if (QR.oekaki.getTegaki()) { cb(); } else if (platform === 'userscript') { QR.oekaki.loadUserscriptTegaki().then(ok => { if (ok && QR.oekaki.getTegaki()) { cb(); } }); } else { const styleAttrs = { rel: 'stylesheet', href: `//s.4cdn.org/css/tegaki.${Date.now()}.css` }; const scriptAttrs = { src: `//s.4cdn.org/js/tegaki.min.${Date.now()}.js` }; const add = (tagName, attrs) => { if ((platform === 'userscript') && (typeof GM_addElement === 'function')) { return GM_addElement(d.head, tagName, attrs); } const el = $.el(tagName, attrs); $.add(d.head, el); return el; }; const style = add('link', styleAttrs); const script = add('script', scriptAttrs); let n = 0; let errored = false; const onload = function () { if (++n === 2 && !errored) cb(); }; const onerror = function () { errored = true; QR.error('Failed to load Tegaki. This site CSP blocked the script.'); }; $.on(style, 'load', onload); $.on(script, 'load', onload); $.on(style, 'error', onerror); $.on(script, 'error', onerror); } }, draw() { if (platform === 'userscript' && QR.oekaki.drawDirect()) { return; } return $.global('qrTegakiDraw'); }, button() { if (QR.selected.file) { QR.oekaki.edit(); } else { QR.oekaki.toggle(); } }, edit() { QR.oekaki.load(() => { if (platform === 'userscript' && QR.oekaki.loadDirect()) { return; } $.global('qrTegakiLoad'); }); }, toggle() { QR.nodes.oekaki.hidden = !QR.nodes.oekaki.hidden; if (!QR.nodes.oekaki.hidden && !QR.oekaki.getTegaki()) { QR.oekaki.load(() => { }); } } }, drafts: { timeout: undefined, FILE_CAP: 100 * 1024 * 1024, _warnedCap: false, _suspended: false, BIN_KEY: 'QR.drafts.bin', BIN_TTL: 24 * 60 * 60 * 1000, key() { const tid = (g.VIEW === 'thread' && g.threadID) ? g.threadID : 'index'; return `${g.SITE.ID}/${g.BOARD.ID}/${tid}`; }, filePrefix() { return `${QR.drafts.key()} `; }, genId() { return `${QR.drafts.filePrefix()}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; }, threadValid(thread) { if (thread == null || thread === 'new') { return true; } return $$('option', QR.nodes.thread).some(o => o.value === `${thread}`); }, save() { if (!Conf['QR Drafts'] || QR.drafts._suspended) { return; } clearTimeout(QR.drafts.timeout); QR.drafts.timeout = window.setTimeout(QR.drafts.flush, 500); }, flush() { clearTimeout(QR.drafts.timeout); if (!Conf['QR Drafts'] || !QR.nodes || QR.drafts._suspended) { return; } QR.selected?.forceSave(); const posts = QR.posts .map(post => ({ thread: post.thread, com: post.com || null, spoiler: post.spoiler ? true : undefined, flag: post.flag || undefined, file: (post.file && post._draftFileId) ? { id: post._draftFileId, filename: post.filename || null, originalName: post.originalName || null } : undefined, })) .filter(post => post.com || post.sub || post.file); $.get('QR.drafts', dict(), ({ 'QR.drafts': all }) => { const key = QR.drafts.key(); if (posts.length) { all[key] = { posts }; } else { delete all[key]; } $.set('QR.drafts', all, QR.drafts.updateButton); QR.drafts.cleanupOrphanFiles(posts.map(p => p.file?.id).filter(Boolean)); }); }, async persistFiles() { if (!Conf['QR Drafts'] || !QR.nodes || QR.drafts._suspended) { return; } try { const withFiles = QR.posts.filter((p) => !!p.file); const keep = new Set(); let total = 0; let skipped = 0; for (const p of withFiles.slice().sort((a, b) => a.file.size - b.file.size)) { if (total + p.file.size <= QR.drafts.FILE_CAP) { total += p.file.size; keep.add(p); } else { skipped++; } } for (const p of withFiles) { if (keep.has(p)) { if (!p._draftFileId) { const id = QR.drafts.genId(); if (await QRFileStore.put(id, p.file)) { p._draftFileId = id; } } } else if (p._draftFileId) { await QRFileStore.delete(p._draftFileId); delete p._draftFileId; } } if (skipped && !QR.drafts._warnedCap) { QR.drafts._warnedCap = true; new Notice('warning', `Draft attachments over ~${Math.round(QR.drafts.FILE_CAP / 1048576)} MB total aren't saved for this thread; ${skipped} file(s) skipped.`, 6); } } catch (err) { console.error('QR draft persistFiles failed', err); } QR.drafts.flush(); }, async cleanupOrphanFiles(keep) { try { const prefix = QR.drafts.filePrefix(); const referenced = new Set(keep); const orphans = (await QRFileStore.keys()).filter(k => k.startsWith(prefix) && !referenced.has(k)); if (orphans.length) { await QRFileStore.delete(orphans); } } catch (err) { console.error('QR draft cleanupOrphanFiles failed', err); } }, restore() { if (!Conf['QR Drafts'] || !QR.nodes) { return; } QR.drafts.migrateLegacy(); QR.drafts.pruneBin(); $.get('QR.drafts', dict(), ({ 'QR.drafts': all }) => { QR.drafts.updateButton(); const data = all?.[QR.drafts.key()]; if (!data?.posts?.length || !QR.nodes) { return; } if (QR.posts.some(post => post.com || post.sub || post.file)) { return; } for (let i = 0; i < data.posts.length; i++) { const draft = data.posts[i]; const post = i === 0 ? QR.posts[0] : new QR.post(); if (!post) { continue; } if (QR.drafts.threadValid(draft.thread)) { post.thread = draft.thread; } post.setComment(draft.com || ''); if (draft.spoiler) { post.spoiler = true; if (post.nodes?.spoiler) { post.nodes.spoiler.checked = true; } } if (draft.flag) { post.flag = draft.flag; } if (draft.file?.id) { QR.drafts.restoreFile(post, draft.file); } } if (data.posts.length > 1 || data.posts.some((p) => p.file?.id)) { $.addClass(QR.nodes.el, 'dump'); } QR.selected?.load(); }); }, async restoreFile(post, ref) { try { const blob = await QRFileStore.get(ref.id); if (!blob || !QR.posts.includes(post)) { return; } const name = ref.originalName || ref.filename || blob.name || 'file'; const file = new File([blob], name, { type: blob.type, lastModified: blob.lastModified || Date.now() }); await post.setFile(file, { restore: true, id: ref.id, filename: ref.filename, originalName: ref.originalName }); } catch (err) { console.error('QR draft restoreFile failed', err); } }, discard() { clearTimeout(QR.drafts.timeout); for (const p of QR.posts) { delete p._draftFileId; } if (QR.nodes) { new QR.post(true); for (const post of QR.posts.splice(0, QR.posts.length - 1)) { post.delete(); } $.rmClass(QR.nodes.el, 'dump'); if (QR.selected) { QR.selected.setComment(''); QR.selected.sub = null; if (QR.nodes.sub) { QR.nodes.sub.value = ''; } } } QR.drafts.clearBoardFiles(); $.get('QR.drafts', dict(), ({ 'QR.drafts': all }) => { delete all[QR.drafts.key()]; $.set('QR.drafts', all, QR.drafts.updateButton); }); }, async clearBoardFiles() { try { const prefix = QR.drafts.filePrefix(); const mine = (await QRFileStore.keys()).filter(k => k.startsWith(prefix)); if (mine.length) { await QRFileStore.delete(mine); } } catch (err) { console.error('QR draft clearBoardFiles failed', err); } }, onSettingChanged(value) { if (typeof value === 'boolean') { Conf['QR Drafts'] = value; } if (!Conf['QR Drafts']) { for (const p of (QR.posts || [])) { delete p._draftFileId; } QR.drafts.clearAll(); } QR.drafts.updateButton(); }, async clearAll() { try { const keys = await QRFileStore.keys(); if (keys.length) { await QRFileStore.delete(keys); } } catch (err) { console.error('QR draft clearAll failed', err); } $.delete('QR.drafts'); $.delete(QR.drafts.BIN_KEY); }, getBin(cb) { $.get(QR.drafts.BIN_KEY, [], (o) => cb(o[QR.drafts.BIN_KEY] || [])); }, binLabel(key, posts) { const board = key.split('/')[1]; const primary = posts.find(p => p.com) || posts[0] || {}; const target = primary.thread; const where = (target == null || target === 'new') ? `New thread /${board}/` : `/${board}/ →${target}`; const raw = (primary.com || '').replace(/\s+/g, ' ').trim(); const snippet = raw ? ` — ${raw.slice(0, 36)}${raw.length > 36 ? '…' : ''}` : ''; return `${where}${snippet}`; }, toBinPosts(posts) { return posts .map(p => ({ thread: p.thread, com: p.com || null, spoiler: p.spoiler ? true : undefined, flag: p.flag || undefined })) .filter(p => p.com); }, binPush(key, posts, opts = {}) { const kept = QR.drafts.toBinPosts(posts); if (!kept.length) { return; } QR.drafts.getBin((bin) => { const entry = { id: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, key, label: QR.drafts.binLabel(key, kept), posts: kept, ts: Date.now(), pinned: false, }; bin.push(entry); $.set(QR.drafts.BIN_KEY, bin, () => { QR.drafts.updateButton(); QR.drafts.renderPanel(); if (opts.notify) { QR.drafts.notifyBinned(entry); } }); }); }, moveToBin(key, opts = {}) { if (!Conf['QR Drafts']) { return; } $.get('QR.drafts', dict(), ({ 'QR.drafts': all }) => { const data = all?.[key]; if (!data?.posts?.length) { return; } delete all[key]; $.set('QR.drafts', all, QR.drafts.updateButton); QR.drafts.deleteKeyFiles(key); QR.drafts.binPush(key, data.posts, opts); }); }, binCurrent() { QR.selected?.forceSave(); QR.drafts.binPush(QR.drafts.key(), QR.posts); QR.drafts.discard(); }, async deleteKeyFiles(key) { try { const prefix = `${key} `; const mine = (await QRFileStore.keys()).filter(k => k.startsWith(prefix)); if (mine.length) { await QRFileStore.delete(mine); } } catch (err) { console.error('QR draft deleteKeyFiles failed', err); } }, notifyBinned(entry) { const el = $.el('div'); $.add(el, $.tn(`Thread died — your unsent draft (${entry.label}) was moved to the QR drafts bin. `)); const keep = $.el('a', { href: 'javascript:;', textContent: 'Keep it' }); $.add(el, keep); $.add(el, $.tn(' so it won\'t auto-clear.')); const notice = new Notice('info', el, 30); $.on(keep, 'click', () => { QR.drafts.pinBinEntry(entry.id, true); keep.textContent = 'Kept ✓'; keep.removeAttribute('href'); setTimeout(() => notice.close(), 1200); }); }, pinBinEntry(id, pinned) { QR.drafts.getBin((bin) => { const entry = bin.find(e => e.id === id); if (!entry) { return; } entry.pinned = pinned; $.set(QR.drafts.BIN_KEY, bin, () => QR.drafts.renderPanel()); }); }, pruneBin() { QR.drafts.getBin((bin) => { const now = Date.now(); const fresh = bin.filter(e => e.pinned || (now - e.ts) <= QR.drafts.BIN_TTL); if (fresh.length === bin.length) { return; } $.set(QR.drafts.BIN_KEY, fresh, QR.drafts.updateButton); }); }, deleteBinEntry(id) { QR.drafts.getBin((bin) => { const fresh = bin.filter(e => e.id !== id); if (fresh.length === bin.length) { return; } $.set(QR.drafts.BIN_KEY, fresh, () => { QR.drafts.updateButton(); QR.drafts.renderPanel(); }); }); }, restoreFromBin(id) { if (!QR.nodes) { return; } if (QR.posts.some(post => post.com || post.sub || post.file)) { new Notice('warning', 'Clear the Quick Reply first, then recover the draft.', 5); return; } QR.drafts.getBin((bin) => { const entry = bin.find(e => e.id === id); if (!entry?.posts?.length) { return; } for (let i = 0; i < entry.posts.length; i++) { const draft = entry.posts[i]; const post = i === 0 ? QR.posts[0] : new QR.post(); if (!post) { continue; } if (QR.drafts.threadValid(draft.thread)) { post.thread = draft.thread; } post.setComment(draft.com || ''); if (draft.spoiler) { post.spoiler = true; if (post.nodes?.spoiler) { post.nodes.spoiler.checked = true; } } if (draft.flag) { post.flag = draft.flag; } } if (entry.posts.length > 1) { $.addClass(QR.nodes.el, 'dump'); } QR.selected?.load(); QR.drafts.deleteBinEntry(id); QR.drafts.closePanel(); }); }, onThreadUpdate(e) { if (!Conf['QR Drafts'] || !e?.detail?.[404]) { return; } if (g.VIEW !== 'thread' || !g.threadID) { return; } QR.drafts.moveToBin(QR.drafts.key(), { notify: true }); }, migrateLegacy() { if (!Conf['QR Drafts']) { return; } $.get('QR.drafts', dict(), ({ 'QR.drafts': all }) => { const legacy = Object.keys(all || {}).filter(k => k.split('/').length === 2); if (!legacy.length) { return; } QR.drafts.getBin((bin) => { for (const key of legacy) { const posts = QR.drafts.toBinPosts(all[key]?.posts || []); if (posts.length) { bin.push({ id: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}${key}`, key: `${key}/index`, label: QR.drafts.binLabel(`${key}/index`, posts), posts, ts: Date.now(), pinned: false, }); } delete all[key]; QR.drafts.deleteKeyFiles(key); } $.set('QR.drafts', all); $.set(QR.drafts.BIN_KEY, bin, QR.drafts.updateButton); }); }); }, updateButton() { const btn = QR.nodes?.draftsButton; if (!btn) { return; } btn.hidden = !Conf['QR Drafts'] || Conf['Show QR Drafts Icon'] === false; if (!Conf['QR Drafts']) { return; } QR.drafts.getBin((bin) => { const badge = QR.nodes?.draftsBadge; if (badge) { badge.textContent = bin.length ? `${bin.length}` : ''; badge.hidden = !bin.length; } }); }, togglePanel() { const panel = QR.nodes?.draftsPanel; if (!panel) { return; } if (panel.hidden) { panel.hidden = false; QR.drafts.renderPanel(); setTimeout(() => $.on(d, 'mousedown', QR.drafts.onOutsideClick)); } else { QR.drafts.closePanel(); } }, onOutsideClick(e) { const panel = QR.nodes?.draftsPanel; const btn = QR.nodes?.draftsButton; const t = e.target; if (panel && !panel.contains(t) && btn && !btn.contains(t)) { QR.drafts.closePanel(); } }, closePanel() { if (QR.nodes?.draftsPanel) { QR.nodes.draftsPanel.hidden = true; } $.off(d, 'mousedown', QR.drafts.onOutsideClick); }, renderPanel() { const panel = QR.nodes?.draftsPanel; if (!panel || panel.hidden) { return; } panel.textContent = ''; const discard = $.el('a', { href: 'javascript:;', className: 'qr-drafts-discard', textContent: '🗑︎ Discard current draft', title: 'Move what\'s in the Quick Reply now to the bin', }); $.on(discard, 'click', () => QR.drafts.binCurrent()); $.add(panel, discard); QR.drafts.getBin((bin) => { if (QR.nodes?.draftsPanel !== panel || panel.hidden) { return; } const list = $.el('div', { className: 'qr-drafts-list' }); if (!bin.length) { $.add(list, $.el('div', { className: 'qr-drafts-empty', textContent: 'No discarded drafts.' })); } const curKey = QR.drafts.key(); const curBoard = curKey.split('/').slice(0, 2).join('/'); const rank = (e) => e.key === curKey ? 0 : (e.key.startsWith(`${curBoard}/`) ? 1 : 2); const ordered = bin.slice().sort((a, b) => rank(a) - rank(b) || b.ts - a.ts); for (const entry of ordered) { const row = $.el('div', { className: 'qr-drafts-row' }); const label = $.el('a', { href: 'javascript:;', className: 'qr-drafts-label', textContent: entry.label, title: 'Recover into Quick Reply' }); const pin = $.el('a', { href: 'javascript:;', className: `qr-drafts-act${entry.pinned ? ' pinned' : ''}`, textContent: '📌', title: entry.pinned ? 'Pinned (won\'t auto-clear). Click to unpin.' : 'Keep past auto-clear' }); const del = $.el('a', { href: 'javascript:;', className: 'qr-drafts-act', textContent: '✕', title: 'Delete permanently' }); $.on(label, 'click', () => QR.drafts.restoreFromBin(entry.id)); $.on(pin, 'click', () => QR.drafts.pinBinEntry(entry.id, !entry.pinned)); $.on(del, 'click', () => QR.drafts.deleteBinEntry(entry.id)); $.add(row, label); $.add(row, pin); $.add(row, del); $.add(list, row); } $.add(panel, list); }); }, }, persona: { always: {}, pwd: '', types: { name: [], email: [], sub: [] }, init() { if (!Conf['Quick Reply'] && (!Conf['Menu'] || !Conf['Delete Link'])) { return; } for (var item of Conf['QR.personas'].split('\n')) { QR.persona.parseItem(item.trim()); } }, parseItem(item) { if (item[0] === '#') return; const regexMatch = item.match(/(name|options|email|subject|password):"(.*)"/i); if (!regexMatch) return; let needle; let [match, type, val] = regexMatch; item = item.replace(match, ''); const boards = item.match(/boards:([^;]+)/i)?.[1].toLowerCase() || 'global'; if ((boards !== 'global') && (needle = g.BOARD.ID, !boards.split(',').includes(needle))) { return; } if (type === 'password') { QR.persona.pwd = val; return; } if (type === 'options') { type = 'email'; } if (type === 'subject') { type = 'sub'; } if (/always/i.test(item)) { QR.persona.always[type] = val; } if (!QR.persona.types[type].includes(val)) { QR.persona.types[type].push(val); } }, load() { if (!QR.nodes) { return; } for (const type of ['name', 'email', 'sub']) { const input = QR.nodes[type]; if (!input) { continue; } const stale = input.nextElementSibling; if (stale && stale.classList.contains('qr-persona-picker')) { $.rm(stale); } const values = (QR.persona.types[type] || []).filter(Boolean); if (!values.length) { continue; } const picker = $.el('select', { className: 'qr-persona-picker', title: 'Choose a saved persona' }); $.add(picker, $.el('option', { value: '', textContent: '' })); for (const val of values) { $.add(picker, $.el('option', { value: val, textContent: val })); } $.on(picker, 'change', function () { if (!this.value) { return; } input.value = this.value; this.selectedIndex = 0; $.event('input', null, input); }); $.after(input, picker); } }, getPassword() { let m; if (QR.persona.pwd != null) { return QR.persona.pwd; } else if (m = d.cookie.match(/4chan_pass=([^;]+)/)) { return decodeURIComponent(m[1]); } else { return ''; } }, get(cb) { $.get('QR.persona', {}, ({ 'QR.persona': persona }) => cb(persona)); }, set(post) { $.get('QR.persona', {}, function ({ 'QR.persona': persona }) { persona = { name: post.name ?? '', flag: post.flag }; $.set('QR.persona', persona); }); } }, }; class post { constructor(select) { this.select = this.select.bind(this); const el = $.el('a', { className: 'qr-preview', draggable: true, href: 'javascript:;' }); $.extend(el, { innerHTML: `${Icon.get('xmark')}` + '' + '
' }); const [rm, spoiler, span, , spanFileName] = el.childNodes; this.nodes = { el, rm, spoiler: spoiler.firstChild, span, spanFileName, }; $.on(el, 'click', this.select); $.on(this.nodes.rm, 'click', e => { e.stopPropagation(); if (!QR.posts.includes(this)) { return; } if (Conf['Dump List Remove File First'] && this.file) { this.rmFile(); } else { this.rm(); } }); $.on(this.nodes.spoiler, 'change', e => { this.spoiler = e.target.checked; if (this === QR.selected) { QR.nodes.spoiler.checked = this.spoiler; } return this.preventAutoPost(); }); for (var label of $$('label', el)) { $.on(label, 'click', e => e.stopPropagation()); } $.add(QR.nodes.dumpList, el); for (var event of ['dragStart', 'dragEnter', 'dragLeave', 'dragOver', 'dragEnd', 'drop']) { $.on(el, event.toLowerCase(), this[event]); } this.thread = g.VIEW === 'thread' ? g.THREADID : 'new'; const prev = QR.posts[QR.posts.length - 1]; QR.posts.push(this); this.nodes.spoiler.checked = (this.spoiler = prev && Conf['Remember Spoiler'] ? prev.spoiler : false); QR.persona.get((persona) => { const clearIdentity = Conf['Clear Name and Options After Posting']; this.name = 'name' in QR.persona.always ? QR.persona.always.name : (clearIdentity ? '' : (prev?.name ?? persona.name ?? '')); this.email = 'email' in QR.persona.always ? QR.persona.always.email : (clearIdentity ? '' : (/^sage$/i.test(prev?.email) ? '' : (prev?.email ?? ''))); this.sub = 'sub' in QR.persona.always ? QR.persona.always.sub : ''; if (QR.nodes.flag) { this.flag = (() => { if (prev) { return prev.flag; } else if (persona.flag && persona.flag in g.BOARD.config.board_flags) { return persona.flag; } })(); } if (QR.selected === this) this.load(); }); if (select) { this.select(); } this.unlock(); QR.captcha.moreNeeded(); } rm() { this.delete(); const index = QR.posts.indexOf(this); if (QR.posts.length === 1) { new QR.post(true); $.rmClass(QR.nodes.el, 'dump'); } else if (this === QR.selected) { (QR.posts[index - 1] || QR.posts[index + 1]).select(); } QR.posts.splice(index, 1); QR.status(); QR.captcha.updateThread?.(); QR.drafts.save(); } delete() { $.rm(this.nodes.el); URL.revokeObjectURL(this.URL); this.dismissErrors(); } lock(lock = true) { this.isLocked = lock; if (this !== QR.selected) { return; } for (var name of ['thread', 'name', 'email', 'sub', 'com', 'fileButton', 'filename', 'spoiler', 'flag']) { var node; if ((node = QR.nodes[name])) { node.disabled = lock; } } this.nodes.rm.style.visibility = lock ? 'hidden' : ''; this.nodes.spoiler.disabled = lock; this.nodes.el.draggable = !lock; } unlock() { this.lock(false); } select() { if (QR.selected) { QR.selected.nodes.el.removeAttribute('id'); QR.selected.forceSave(); } QR.selected = this; this.lock(this.isLocked); this.nodes.el.id = 'selected'; const rectEl = this.nodes.el.getBoundingClientRect(); const rectList = this.nodes.el.parentNode.getBoundingClientRect(); this.nodes.el.parentNode.scrollLeft += (rectEl.left + (rectEl.width / 2)) - rectList.left - (rectList.width / 2); this.load(); } load() { for (var name of ['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag']) { var node; if (!(node = QR.nodes[name])) { continue; } node.value = this[name] || node.dataset.default || ''; } QR.updateFlagSelector?.(); (this.thread !== 'new' ? $.addClass : $.rmClass)(QR.nodes.el, 'reply-to-thread'); this.showFileData(); QR.characterCount(); QR.refreshCommentPreview(); } save(input, forced) { if (input.type === 'checkbox') { this.spoiler = input.checked; return; } const { name } = input.dataset; if (!name || !['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag'].includes(name)) { return; } const prev = this[name] || input.dataset.default || null; this[name] = input.value || input.dataset.default || null; switch (name) { case 'thread': (this.thread !== 'new' ? $.addClass : $.rmClass)(QR.nodes.el, 'reply-to-thread'); QR.status(); QR.captcha.updateThread?.(); break; case 'com': this.updateComment(); break; case 'filename': if (!this.file) { return; } this.saveFilename(); this.updateFilename(); break; case 'name': case 'flag': if (this[name] !== prev && !Conf['Clear Name and Options After Posting']) { QR.persona.set(this); } break; } if (!forced) this.preventAutoPost(); } forceSave() { if (this !== QR.selected) { return; } for (var name of ['thread', 'name', 'email', 'sub', 'com', 'filename', 'spoiler', 'flag']) { var node; if (!(node = QR.nodes[name])) { continue; } this.save(node, true); } } preventAutoPost() { if (QR.cooldown.auto && (this === QR.posts[0])) { QR.cooldown.update(); if (QR.cooldown.seconds <= 5) QR.cooldown.auto = false; } } setComment(com) { this.com = (com || null); if (this === QR.selected) { QR.nodes.com.value = this.com || ''; } return this.updateComment(); } updateComment() { if (this === QR.selected) { QR.characterCount(); QR.refreshCommentPreview(); } this.nodes.span.textContent = this.com || ''; QR.captcha.moreNeeded(); } isOnlyQuotes() { return (this.com || '').trim() === (this.quotedText || '').trim(); } static rmErrored(e) { e.stopPropagation(); for (let i = QR.posts.length - 1; i >= 0; i--) { var errors; var post = QR.posts[i]; if ((errors = post.errors)) { for (var error of errors) { if (doc.contains(error)) { post.rm(); break; } } } } } error(className, message, link, actions) { const div = $.el('div', { className }); const actionsHTML = (actions || []) .map(action => `[${E(action.label)}] `) .join(''); $.extend(div, { innerHTML: message + (link ? ` [More info]` : '') + `
${actionsHTML}[delete post] [delete all]` }); (this.errors || (this.errors = [])).push(div); const anchors = $$('a', div); const rm = anchors[anchors.length - 2]; const rmAll = anchors[anchors.length - 1]; $.on(div, 'click', () => { if (QR.posts.includes(this)) this.select(); }); $.on(rm, 'click', e => { e.stopPropagation(); if (QR.posts.includes(this)) this.rm(); }); $.on(rmAll, 'click', QR.post.rmErrored); $$('.qr-error-action', div).forEach((anchor, index) => { $.on(anchor, 'click', e => { e.stopPropagation(); actions[index].onClick(); if (doc.contains(div)) { div.parentNode?.previousElementSibling?.click?.(); } }); }); QR.error(div, true); } fileError(message, link, actions) { this.error('file-error', `${this.filename}: ${message}`, link, actions); } dismissErrors(test = () => true) { if (this.errors) { for (var error of this.errors) { if (doc.contains(error) && test(error)) { error.parentNode.previousElementSibling.click(); } } } } async validateFile(file) { const autoProcessImages = !!Conf['Auto-process Images']; if (location.hostname.endsWith('4chan.org') && !QR.mimeTypes.includes(file.type)) { if (autoProcessImages && file.type.startsWith('image/')) { const msg = `The ${file.type.slice(6)} image was converted to png.`; file = await QR.convert(file, 'png'); new Notice('info', msg, 3); } else { throw new Error('Unsupported file type.'); } } const maxSize = QR.getMaxSize(file); if (file.type.startsWith('image/')) { let img = await createImageBitmap(file); const { width: originalW, height: originalH } = img; let width = originalW, height = originalH; if (autoProcessImages) { if (width > QR.max_width) { height = Math.round(height * (QR.max_width / width)); width = QR.max_width; } if (height > QR.max_height) { width = Math.round(width * (QR.max_height / height)); height = QR.max_height; } if (width !== originalW || height !== originalH) { file = await QR.convert(file, file.type === 'image/jpeg' ? 'jpeg' : 'png', { width, height, img }); img = undefined; new Notice('warning', `Image was too large got shrunk from ${originalW} * ${originalH} to ${width} * ${height}.` + 'It might have lost animation.'); } } else if ((width > QR.max_width) || (height > QR.max_height)) { throw new Error(`Image too large (image: ${originalW}x${originalH}px, max: ${QR.max_width}x${QR.max_height}px).`); } if (file.size > maxSize) { if (!autoProcessImages) { throw new Error(`File too large (file: ${$.bytesToString(file.size)}, max: ${$.bytesToString(maxSize)}).`); } const originalSize = file.size; file = await QR.convert(file, 'jpeg', { maxSize, img }); new Notice('warning', `Image was too large (${$.bytesToString(originalSize)}) and got converted to jpg (` + `${$.bytesToString(file.size)}). It might have lost transparency or animation.`); } } else if (file.size > maxSize) { if (!file.type.startsWith('video/')) { throw new Error(`File too large (file: ${$.bytesToString(file.size)}, max: ${$.bytesToString(maxSize)}).`); } const sizes = `file: ${$.bytesToString(file.size)}, max: ${$.bytesToString(maxSize)}`; if (Conf['Dogiri Editor']) { this.fileError(`File too large (${sizes}). Trim it before posting.`, undefined, [ { label: 'edit in Dōgiri', onClick: () => { if (!this.file) { return; } Dogiri.open(this.file, { audioStripped: this.audioWasStripped, originalFile: this.preStripFile, maxSize: QR.getMaxSize(this.file), onExportDone: (exported, keepOriginal) => { if (keepOriginal) { $.addClass(QR.nodes.el, 'dump'); new QR.post(true).setFile(exported); return; } this.dismissErrors(error => $.hasClass(error, 'file-error')); this.setFile(exported); }, }); }, }, ]); } else { this.fileError(`File too large (${sizes}). It cannot be posted until it is under the limit.`); } } return file; } async setFile(file, opts = {}) { this.pendingFile = true; if (!opts.restore) { delete this._draftFileId; } if (!opts.restore) { this.audioWasStripped = false; delete this.preStripFile; } try { if (!opts.restore && Conf['Strip Video Audio'] && BoardConfig.noAudio(g.BOARD.ID) && (/^video\/(webm|mp4)$/.test(file.type) || /\.(webm|mp4)$/i.test(file.name))) { const stripped = await VideoStripper.stripAudio(file); if (stripped !== file) { this.preStripFile = file; file = stripped; this.audioWasStripped = true; QR.noteAudioStripped(); } } this.file = file; this.filename = file.name; this.originalName = file.name; this.file = await this.validateFile(file); this.originalName = opts.restore ? (opts.originalName || file.name) : file.name; if (opts.restore) { this.filename = opts.filename || this.file.name; } else if (Conf['Randomize Filename'] && (g.BOARD.ID !== 'f') && (!this.file.name.toLowerCase().includes('[sound='))) { this.randomizeName(false); } else { this.filename = this.file.name; } this.filesize = $.bytesToString(this.file.size); $.addClass(this.nodes.el, 'has-file', 'has-' + this.file.type.split('/')[0]); QR.captcha.moreNeeded(); URL.revokeObjectURL(this.URL); this.saveFilename(); if (this === QR.selected) { this.showFileData(); } else { this.updateFilename(); } this.rmMetadata(); this.nodes.el.dataset.type = this.file.type; this.nodes.el.style.backgroundImage = ''; if (/^(image|video)\//.test(this.file.type)) { this.nodes.spanFileName.textContent = ''; this.readFile(); } else { this.nodes.spanFileName.textContent = this.file.name.match(/\.([^\.]+)$/)?.[1] || ''; } if (opts.restore) { this._draftFileId = opts.id; } else { QR.drafts.persistFiles(); } } catch (error) { console.error(error); this.fileError(error instanceof Error ? error.message : String(error || 'unknown error when setting a file')); } finally { delete this.pendingFile; } this.preventAutoPost(); } randomizeName(set = true) { this.filename = `${Date.now() * 1000 - Math.floor(Math.random() * 365 * DAY * 1000)}`; const ext = this.file.name.match(QR.validExtension); if (ext) this.filename += ext[0]; if (set) QR.nodes.filename.value = this.filename; } restoreName() { QR.nodes.filename.value = this.filename = this.originalName; } readFile() { const isVideo = /^video\//.test(this.file.type); const el = $.el(isVideo ? 'video' : 'img'); if (isVideo && !el.canPlayType(this.file.type)) { return; } const event = isVideo ? 'loadeddata' : 'load'; var onload = () => { $.off(el, event, onload); $.off(el, 'error', onerror); this.checkDimensions(el); this.setThumbnail(el); $.event('QRMetadata', null, this.nodes.el); }; var onerror = () => { $.off(el, event, onload); $.off(el, 'error', onerror); this.fileError(`Corrupt ${isVideo ? 'video' : 'image'} or error reading metadata.`, meta.upstreamFaq + '#error-reading-metadata'); URL.revokeObjectURL(el.src); this.nodes.el.removeAttribute('data-height'); $.event('QRMetadata', null, this.nodes.el); }; this.nodes.el.dataset.height = 'loading'; $.on(el, event, onload); $.on(el, 'error', onerror); el.src = URL.createObjectURL(this.file); } checkDimensions(el) { let height, width; if (el.tagName === 'IMG') { ({ height, width } = el); this.nodes.el.dataset.height = height; this.nodes.el.dataset.width = width; if ((height > QR.max_height) || (width > QR.max_width)) { this.fileError(`Image too large (image: ${height}x${width}px, max: ${QR.max_height}x${QR.max_width}px)`); } if ((height < QR.min_height) || (width < QR.min_width)) { this.fileError(`Image too small (image: ${height}x${width}px, min: ${QR.min_height}x${QR.min_width}px)`); } } else { const { videoHeight, videoWidth, duration } = el; this.nodes.el.dataset.height = videoHeight; this.nodes.el.dataset.width = videoWidth; this.nodes.el.dataset.duration = duration; const max_height = Math.min(QR.max_height, QR.max_height_video); const max_width = Math.min(QR.max_width, QR.max_width_video); if ((videoHeight > max_height) || (videoWidth > max_width)) { this.fileError(`Video too large (video: ${videoHeight}x${videoWidth}px, max: ${max_height}x${max_width}px)`); } if ((videoHeight < QR.min_height) || (videoWidth < QR.min_width)) { this.fileError(`Video too small (video: ${videoHeight}x${videoWidth}px, min: ${QR.min_height}x${QR.min_width}px)`); } if (!isFinite(duration)) { this.fileError('Video lacks duration metadata (try remuxing)'); } else if (duration > QR.max_duration_video) { this.fileError(`Video too long (video: ${duration}s, max: ${QR.max_duration_video}s)`); } if (BoardConfig.noAudio(g.BOARD.ID) && $.hasAudio(el)) { this.fileError('Audio not allowed'); } } } setThumbnail(el) { let height, width; const isVideo = el.tagName === 'VIDEO'; let s = 90 * 2 * window.devicePixelRatio; if (this.file.type === 'image/gif') { s *= 3; } if (isVideo) { height = el.videoHeight; width = el.videoWidth; } else { ({ height, width } = el); if ((height < s) || (width < s)) { this.URL = el.src; this.nodes.el.style.backgroundImage = `url(${this.URL})`; return; } } if (height <= width) { width = (s / height) * width; height = s; } else { height = (s / width) * height; width = s; } const cv = $.el('canvas'); cv.height = height; cv.width = width; const drawThumbNail = () => { cv.getContext('2d').drawImage(el, 0, 0, width, height); URL.revokeObjectURL(el.src); cv.toBlob(blob => { this.URL = URL.createObjectURL(blob); this.nodes.el.style.backgroundImage = `url(${this.URL})`; }); }; if (isVideo) { el.currentTime = 0; el.addEventListener("seeked", drawThumbNail); } else { drawThumbNail(); } } rmFile() { if (this.isLocked) { return; } delete this.file; delete this.filename; delete this.filesize; delete this._draftFileId; delete this.audioWasStripped; delete this.preStripFile; QR.drafts.persistFiles(); this.nodes.el.removeAttribute('title'); QR.nodes.filename.removeAttribute('title'); this.rmMetadata(); this.nodes.el.style.backgroundImage = ''; $.rmClass(this.nodes.el, 'has-file', 'has-image', 'has-video'); this.showFileData(); URL.revokeObjectURL(this.URL); this.dismissErrors(error => $.hasClass(error, 'file-error')); this.preventAutoPost(); } rmMetadata() { for (var attr of ['type', 'height', 'width', 'duration']) { this.nodes.el.removeAttribute(`data-${attr}`); } } saveFilename() { this.file.newName = (this.filename || '').replace(/[/\\]/g, '-'); if (!QR.validExtension.test(this.filename || '')) { this.file.newName += `.${$.getOwn(QR.extensionFromType, this.file.type) || 'jpg'}`; } } updateFilename() { const long = `${this.filename} (${this.filesize})`; this.nodes.el.title = long; if (this !== QR.selected) { return; } QR.nodes.filename.title = long; } showFileData() { if (this.file) { this.updateFilename(); QR.nodes.filename.value = this.filename; $.addClass(QR.nodes.oekaki, 'has-file'); $.addClass(QR.nodes.fileSubmit, 'has-file', 'has-' + this.file.type.split('/')[0]); } else { $.rmClass(QR.nodes.oekaki, 'has-file'); $.rmClass(QR.nodes.fileSubmit, 'has-file', 'has-image', 'has-video'); } if (this.file?.source != null) { QR.nodes.fileSubmit.dataset.source = this.file.source; } else { QR.nodes.fileSubmit.removeAttribute('data-source'); } QR.nodes.spoiler.checked = this.spoiler; } pasteText(file) { this.pasting = true; this.preventAutoPost(); const reader = new FileReader(); reader.onload = e => { const { result } = e.target; this.setComment((this.com ? `${this.com}\n${result}` : result)); delete this.pasting; }; reader.readAsText(file); } dragStart(e) { const { left, top } = this.getBoundingClientRect(); e.dataTransfer.setDragImage(this, e.clientX - left, e.clientY - top); $.addClass(this, 'drag'); } dragEnd() { $.rmClass(this, 'drag'); } dragEnter() { $.addClass(this, 'over'); } dragLeave() { $.rmClass(this, 'over'); } dragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } drop(e) { $.rmClass(this, 'over'); if (e.dataTransfer?.files?.length) { return; } if (!this.draggable) { return; } const el = $('.drag', this.parentNode); if (!el) { return; } const index = (el) => { for (let i = 0; i < el.parentNode.children.length; i++) { if (el.parentNode.children[i] === el) return i; } return -1; }; const oldIndex = index(el); const newIndex = index(this); if (QR.posts[oldIndex].isLocked || QR.posts[newIndex].isLocked) { return; } (oldIndex < newIndex ? $.after : $.before)(this, el); const post = QR.posts.splice(oldIndex, 1)[0]; QR.posts.splice(newIndex, 0, post); QR.status(); QR.captcha.updateThread?.(); } } QR.post = post; const violentmonkeyDropsBinaryResponses = (() => { try { const info = (typeof GM !== 'undefined' && GM != null) ? GM.info : (typeof GM_info !== 'undefined' && GM_info != null) ? GM_info : null; return info?.scriptHandler === 'Violentmonkey'; } catch (error) { return false; } })(); var CrossOrigin = { binary(url, cb, headers = dict()) { url = url.replace(/^((?:https?:)?\/\/(?:\w+\.)?(?:4chan|4channel|4cdn)\.org)\/adv\//, '$1//adv/'); const fallback = function () { return $.ajax(url, { headers, responseType: 'arraybuffer', onloadend() { if (this.status && this.response) { return cb(new Uint8Array(this.response), this.getAllResponseHeaders()); } else { return cb(null); } } }); }; if ((typeof window.GM_xmlhttpRequest === 'undefined' || window.GM_xmlhttpRequest === null)) { fallback(); return; } const gmOptions = { method: "GET", anonymous: true, url, headers, responseType: 'arraybuffer', overrideMimeType: 'text/plain; charset=x-user-defined', onload(xhr) { let data; if (xhr.response instanceof ArrayBuffer) { data = new Uint8Array(xhr.response); } else { const r = xhr.responseText; data = new Uint8Array(r.length); let i = 0; while (i < r.length) { data[i] = r.charCodeAt(i); i++; } } return cb(data, xhr.responseHeaders); }, onerror() { return cb(null); }, onabort() { return cb(null); } }; if (violentmonkeyDropsBinaryResponses) { delete gmOptions.responseType; } try { return (GM?.xmlHttpRequest || GM_xmlhttpRequest)(gmOptions); } catch (error) { return fallback(); } }, file(url, cb) { return CrossOrigin.binary(url, function (data, headers) { if (data == null) { return cb(null); } let name = url.match(/([^\/?#]+)\/*(?:$|[?#])/)?.[1] || 'file'; const contentType = headers.match(/Content-Type:\s*(.*)/i)?.[1]; const contentDisposition = headers.match(/Content-Disposition:\s*(.*)/i)?.[1]; let mime = contentType?.match(/[^;]*/)[0] || 'application/octet-stream'; const match = contentDisposition?.match(/\bfilename\s*=\s*"((\\"|[^"])+)"/i)?.[1] || contentType?.match(/\bname\s*=\s*"((\\"|[^"])+)"/i)?.[1]; if (match) { name = match.replace(/\\"/g, '"'); } if (/^text\/plain;\s*charset=x-user-defined$/i.test(mime)) { const extension = name.match(/[^.]*$/)?.[0]?.toLowerCase() || ''; mime = $.getOwn(QR.typeFromExtension, extension) || 'application/octet-stream'; } cb(new File([data], name, { type: mime })); }); }, Request: (function () { const Request = class Request { static initClass() { this.prototype.status = 0; this.prototype.statusText = ''; this.prototype.response = null; this.prototype.responseHeaderString = null; } getResponseHeader(headerName) { if ((this.responseHeaders == null) && (this.responseHeaderString != null)) { this.responseHeaders = dict(); for (var header of this.responseHeaderString.split('\r\n')) { var i; if ((i = header.indexOf(':')) >= 0) { var key = header.slice(0, i).trim().toLowerCase(); var val = header.slice(i + 1).trim(); this.responseHeaders[key] = val; } } } return this.responseHeaders?.[headerName.toLowerCase()] ?? null; } abort() { } onloadend() { } }; Request.initClass(); return Request; })(), ajax(url, options = {}) { let gmReq; let { onloadend, timeout, responseType, headers } = options; if (responseType == null) { responseType = 'json'; } const req = new CrossOrigin.Request(); if (onloadend) req.onloadend = onloadend; if (window.GM?.xmlHttpRequest == null && window.GM_xmlhttpRequest == null) { return $.ajax(url, options); } const gmOptions = { method: 'GET', anonymous: true, url, headers, timeout, onload(xhr) { try { let response = xhr.responseText; if (responseType === 'json' && [200, 203, 206, 304].includes(xhr.status)) { try { response = JSON.parse(xhr.responseText); } catch (error) { console.error(error); console.error(xhr); } } $.extend(req, { url, headers, response, status: xhr.status, statusText: xhr.statusText, responseHeaderString: xhr.responseHeaders }); } catch (error) { } return req.onloadend(); }, onerror() { return req.onloadend(); }, onabort() { return req.onloadend(); }, ontimeout() { return req.onloadend(); } }; try { gmReq = (GM?.xmlHttpRequest || GM_xmlhttpRequest)(gmOptions); } catch (error) { return $.ajax(url, options); } if (gmReq && (typeof gmReq.abort === 'function')) { req.abort = function () { try { return gmReq.abort(); } catch (error1) { } }; } return req; }, ajaxPromise(url, options = {}) { return new Promise((resolve) => CrossOrigin.ajax(url, { ...options, onloadend() { resolve(this); } })); }, cache(url, cb) { return $.cache(url, cb, { ajax: CrossOrigin.ajax }); }, cachePromise(url) { return new Promise(resolve => { CrossOrigin.cache(url, function () { resolve(this); }); }); }, permission(cb, cbFail, origins) { return cb(); }, }; const noWebmTitleMetadataSites = ['4chan.org']; var Metadata = { init() { if (!Conf['WEBM Metadata'] || (g.VIEW !== 'index' && g.VIEW !== 'thread')) { return; } if (noWebmTitleMetadataSites.includes(g.SITE.ID)) { return; } return Callbacks.Post.push({ name: 'WEBM Metadata', cb: this.node }); }, node() { for (let i = 0; i < this.files.length; i++) { var file = this.files[i]; if (/webm$/i.test(file.url)) { var el; if (this.isClone) { el = $('.webm-title', file.text); } else { el = $.el('span', { className: 'webm-title' }); el.dataset.index = String(i); $.extend(el, { innerHTML: "" }); $.add(file.text, [$.tn(' '), el]); } if (el.children.length === 1) { $.one(el.lastElementChild, 'mouseover focus', Metadata.load); } } } }, load() { $.rmClass(this.parentNode, 'error'); $.addClass(this.parentNode, 'loading'); const { index } = this.parentNode.dataset; const post = Get.postFromNode(this); if (!post) { return; } return CrossOrigin.binary(post.files[+index].url, data => { $.rmClass(this.parentNode, 'loading'); if (data != null) { const title = Metadata.parse(data); const output = $.el('span', { textContent: title || '' }); if (title == null) { $.addClass(this.parentNode, 'not-found'); } $.before(this, output); this.parentNode.tabIndex = 0; if (d.activeElement === this) { this.parentNode.focus(); } return this.tabIndex = -1; } else { $.addClass(this.parentNode, 'error'); return $.one(this, 'click', Metadata.load); } }, { Range: 'bytes=0-9999' }); }, parse(data) { const readInt = function () { let n = data[i++]; let len = 0; while (n < (0x80 >> len)) { len++; } n ^= (0x80 >> len); while (len-- && (i < data.length)) { n = (n << 8) ^ data[i++]; } return n; }; var i = 0; while (i < data.length) { var element = readInt(); var size = readInt(); if (element === 0x3BA9) { var title = ''; while (size-- && (i < data.length)) { title += String.fromCharCode(data[i++]); } return decodeURIComponent(escape(title)); } else if (![0x8538067, 0x549A966].includes(element)) { i += size; } } return null; } }; var burichan = `:root.burichan{--xt-background:#D6DAF0;--xt-border:#B7C5D9;--xt-border-field-focus:#98E;--xt-border-highlight:rgba(221,0,0,.8);--xt-filter-highlight:rgba(221,0,0,.5);--xt-highlight-shadow:rgba(221,0,0,.5);--xt-highlight-side-arrow:rgba(221,0,0,.8);--xt-watched-border:rgb(221,0,0);--xt-header-dialog-bg:rgba(214,218,240,0.98);--xt-notification-size:11pt;--xt-header-dialog-fg:#89A;--xt-header-link:#34345C;--xt-dead-link:#34345C;--xt-qr-link-border:rgb(199,203,225) rgb(199,203,225) rgb(184,188,210);--xt-qr-bg:linear-gradient(#E5E9FF,#D6DAF0) repeat scroll 0% 0% transparent;--xt-menu-fg:#000;--xt-entry-size:12pt;--xt-entry-focus-bg:rgba(255,255,255,.33);--xt-link-hover-bg:#D9DDF3;--xt-unread:rgba(214,218,240,0.5)}:root.burichan.anonymize __xt-site-info-name::before{font-size:12pt}`; var futaba = `:root.futaba{--xt-background:#F0E0D6;--xt-border:#D9BFB7;--xt-border-field-focus:#EA8;--xt-border-highlight:rgba(221,0,0,.8);--xt-filter-highlight:rgba(221,0,0,.5);--xt-highlight-shadow:rgba(221,0,0,.5);--xt-highlight-side-arrow:rgba(221,0,0,.8);--xt-watched-border:rgb(221,0,0);--xt-notification-size:11pt;--xt-header-dialog-fg:#B86;--xt-dead-link:#00E;--xt-header-dialog-bg:rgba(240,224,214,0.98);--xt-header-link:#800000;--xt-qr-link-border:rgb(225,209,199) rgb(225,209,199) rgb(210,194,184);--xt-qr-bg:linear-gradient(#FFEFE5,#F0E0D6) repeat scroll 0% 0% transparent;--xt-entry-size:12pt;--xt-entry-focus-bg:rgba(255,255,255,.33);--xt-unread:rgba(240,224,214,0.5);--xt-watcher:#800000;--xt-fxt-fg:#800000}:root.futaba.anonymize __xt-site-info-name::before{font-size:12pt}`; var linkifyAudio = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAMBQTFRFAAAAAEmSADyHADN6CEOPKmWmADiKNV6lZoO5E2asU3u8B02hXYTAFXK8BEakS3i7MGS0E1mwZYzHSHjCYIrLIGDICFPDHXrIFWnBEGDDdZzWB1G2UYHMH4DPBU60EYrSSX7PGofXfajkeaPgQHzVSoHSDmHLF3vRjLbtea3sa6rsXJ3oSaHrYZXhW5blIKzwPZrqVozbKp7sRonfHpjoJ4vhRHrQLn/eNXnaJ33fNnHMJGvUFG3ZI2THBlPGAU2+9r1cgwAAACh0Uk5TAAcRGSIrO0RQUF9qdnl5hISEiaSoqqqxssnPz9DY2ery+vv7/P39/p/dKS0AAACXSURBVHjaXdDZEoIwDAXQVMuiIFrcd1FQWSqiouD6/38ltVNkel+SnMnkIfAPwrrZBB5Ft3uzlZs9Bnyuewml9HzLnwISP4hSZz4clxujVkNBAETAVeVNpy9BW8BdhpcMbw6oPPphoE027g80u8s21HUQ5gzMvX9gYGXH+EIK8MLotMUAeLpbElTAIo0dC1hqwIth4MoTvt45DOQxQMWhAAAAAElFTkSuQmCC'; var linkifyBitchute = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAMBQTFRFAAAAgICAzDMzzDMz0i0tzjEx0i0t0S4u0C8v1Csr1Ssr1Csr1Csr0ykp1Ckp0yws1Soq1Soq0i0t1Soq1Csr1Csr1Ckp1ikp1Skp1ikp1ikp1ikp1ygo1ygo1ygo1igo1ygo1ygo1ygo2Ccn2Ccn+enp9+Pj9t7e9NHR9MzM8bu78LS076ys65ub65WV6YqK6ImJ54KC5XR05G5u4V9f4FRU3UhI3UVF3UFB3Dw82jU12S8v2Soq2Ccn2CYm2CQklRbHSwAAACV0Uk5TAAIFChEaIicxO0JNWWNqb3R4iZmboaettLzF1t3j6evt8/f7/Ut/7/wAAAC+SURBVHjaTcfRVoJAFIXhPaGZJqQVGIgWDFYWUZnkqOw57/9Wji4Xy++/OGfDUddBGMdh0FM46Yymmo6ejjtwulHBs2LSBbxHXgg9DDOKtZZ0RyT3EZNmv91Z7uttbWyCZzGfdbkWWS/Kt7LJkMvva7VYiZj3748vq5HK3/JnuRJrqk31L3OEbHYuko2LE/RntK6z+QDqLmMrHynAe2r3y9gDcJOSLEg9iwYKQD+hTh+Gvn/bu8LRfRIF7m0dAEgnJl0bT2c0AAAAAElFTkSuQmCC'; var linkifyClyp = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IArs4c6QAAAC1QTFRF////8vn9zOX2v9/0s9jxptLvmsztjcXrgL/pdLjmZ7LkWqviTaXgQZ7dNJjb3UEC1wAAAFpJREFUeNpjeAcFDMiMV+egjKd5EMbzBs06MOOQgpIOiPFGSUFJ9dw7hjfKQIaS6DuGV0IgBhNQ6gaQodIHZLxrUlDSegdiPE5QswMz3l3WewdhvNgHYmDYDgDke1tpAZA/swAAAABJRU5ErkJggg=='; var linkifyDailymotion = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAXNSR0IArs4c6QAAAMxJREFUeNqFkdENhSAMRRu/bUz4dBMGYA1GYQs/+XQFB3AFV3AO+q634SU+TR4NxdjT20IFa7hciFqnc25zm06tIQoj3Q1axbqpzU1NKyOOjLtasuyGsBAZd/EFFuHDNltoOKkDq6x9ZWT8XhkugBd8u0qIyFeKLtQoVuAzUSJV0DPlBFkbcxNObKZNp3iYKsQKEOgAzdT4AgJLpg7QO8AS3uZh61uJ3mQh8NJkiF4gscn1eU3pGtjQeDxUf2oi8vPUt2FxRPdhMfp33B/2Tf349B8MPAAAAABJRU5ErkJggg=='; var linkifyGfycat = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAjVBMVEWn3gCo3gSr3w2t4BSu4Bav4Ri35C+45DK45DO55DXA50rA50vB50zC6E/D6FTF6VjG6VvL62vN7G/P7XbQ7XfW74vY8JDa8ZTe8qDe8qLf86Pi9Kzj9K7k9LHp9sDp98Lq98Ps+Mr0++L5/O75/fD6/fH6/fL6/fP7/fT7/fb8/ff8/vj8/vn+/v7///91X4cfAAAAcklEQVR42o3M2xKBUACF4aVQckrIuRJK6H//x2sme4/MuPDfre9i6c/Cc3U5Dj87BuAxsXvGu6JvIIXEHRWwNHCHQNrCzkAFkbSBg4EM8i+Yw7PXBa3zRfuxVyf/Bis7nKwGKAcWxgC8prI5Sc315OlnDfzpDar2S9/oAAAAAElFTkSuQmCC'; var linkifyGist = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IArs4c6QAAACpQTFRFAAAAaWlpubm5zc3PycrL7u7uCxAX/v7/2NnasbK1gYSIWFxhLTI4GR4lkI5WFQAAAAd0Uk5TADicvur2/XGJAcwAAACHSURBVHjaY2BgEPKcosgABEyzz97ZqQBkWOWeOXNtMVBg2Z67d09nKTBI5lRM8Ww/NpFB+054eXn52U0MPrfKgWDtEYa5N0GMuTcRDJ/bIMbeIwzaZ5aXl1fd3cQgefLs9MqzQO1MuyPDK+5kKzAwWO8Kr7i2GWTpmvaKkwoMQCAUXqrIwAAAoI46gu0lGAYAAAAASUVORK5CYII='; var linkifyImage = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAoJJREFUeNqlk8trU1sYxX/7cc7JSWqb1Da59iEpXr0ovdxcQcSJFnxMxaGOHDv1D1H/AsWhOlJwogPRsVAFJ+KjSqpto/akycnZ57W3AVEQOnPB4oPF+taafB9/CnH55qtOd7hT/yksz9TXrl88vMYuuHLnVef15u9erZS6dmRvc8U5R54Zzi656Gq3u/rbpnOA49maWfGKJo4fUrOWPtEWxzAesh5P4ck6j7p/1R+vixUtobAwVXUcWpS0W7BnARpmg/WtPnZUkAYlMjM5cRzTmp6nNTtPP1NsG8kglwzH9KqKoRLIMaNCICb38c9Sk370lSzP0aIYcubfWcI5zdQE1BR8+OLIckd/JAkCmJ8ALCxOQlHCqJjm5KEa35ISfenA4MaxE8dWXg4sA5OOG0P+bwu2YkFZOhACTwEK6j6cqDEOgKqewFq3quOdnWhj8yO3795DKcPpcxdY3L9MWBVoIZj0ILFgHRRALKARQlpaHPQ1QJYmfN56S1EYHjy8xUx9ieOd4xw+eJRu5LA4NDn7WyGBFGggcRaERPfkTGdhlPL5/UekckS9HltzPd51X6KrAb7vUwmaVMO91EJNZ+lvOu02zgrWBxn6edw6v/ZiA+kfQSiNVIJ0EGCLKmEeoKsV8tRRFB5ah3za3GZnkPBtMI/nBae0RfC1nMFvHEBpHz8MCXyfiYoaTw9PSXxPMr0nZLoWEkpFNhq3R4K5RoF2pcM52LdwEF/lnFluoaVACoGSDhx4WhJ4iszC0zcJoyRnlCbMjgO1SQ0mHpE5iE0J2ZD/FmrgHO7XJZdAxv03ll4sMSYnMUOkDBDbyXbbRKYNAFCpVKJGo7HKLtjN+8fv/B2VaRzENkpWcwAAAABJRU5ErkJggg=='; var linkifyInstallgentoo = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAMBQTFRFAAAAAAAAAAAAAAAAAAAAAAAAd3KIAAAAEQ4RAwULzs7oAAcM2NbmlIfG5+P4vbPyAAAAAAAAAgkQ7Or4DA8YAAAAERASAwsSubXX3tn07+/3AwwUDCY15OD2XV1nS1BaEzJEZ3iOECs9IFBtaGx7RGmCJVl50Mr48fD4bmuk9vb7xM/jWYGf/f77+fj75+H839v719P2x737uKv2harYe6HSSJfLaoS/RZLDQo28Poe1O4CsLWeKSlpzJ1p+FzxSneqjOwAAAC10Uk5TAAYNEBkbL0tZXmNrcHmJjI2aoqqqrLS6u87m6Onr7e7w8vX5+vr6+/39/v7+2FamfQAAALBJREFUeNpljesaQkAYhHdRLKWSjjqIQrRyKFlK939X2bX1p+/P+80888wAAERJgi1+j2hYFqKPwJ+xW1c2gq32qnojAyAtngU5LEemWxZvTegSEQ7T/FHVNEE79hivVD8/8zJR6k/Dte5n+ZCtUKs3wHGS+SoVbG8X4DBJjwhyfQpwHEQXYnfOxL0l160+bwriyNSYNeT+UihKR2EJz/YcmYN1mMjQIEe3CkX4xf99ABzOEyTh/fKEAAAAAElFTkSuQmCC'; var linkifyLiveleak = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAALRQTFRFcDk5Wx0doj09mSIi/Pv77/Dw6ezs29ra1tLSwrq6wGVlpF1di1ZWjSkpfhER9fT04ubm68zM19LSw7S02aqqzqamvqurwaGhsJOTunR0s3FxqW5uo2xsmm9vtGJimWNjkVxcuUpKtEZGsDU1pSoqeTk5rBIScCkprQsLlxAQpAEBnQICaxUVlAICiAEBYg8PewQEgAEBdQICbAICWwICtpycyHl5p4eHqAAAYQkJXQoKVgAAewTIdwAAADV0Uk5TmZ+or/39/f39/f39/f39/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v5oWOIfAAAAuElEQVR42kXKwS4DYRhA0fPPfKrtpM0sOpsmiCDdef9H8ABWhE0RDWo61dEIHcTC3Z3kRn5W+q++il83xp46pOo0ygaa7d0A7W4vmvfFMR4Hs+s1VR2pWmdgmUMefzJZUUCYpwTtot8yLyOlLP+SvyDpksiTLu8gI7mP7meRSNMaSWT5ZwIMIZIVJlWt3Nl2h9Eb9TB+83rE7VDo9/HxHM1NqxsJ++BkOcPDtImL88Km2BQFHGwuvwGKmzk9p5YkgwAAAABJRU5ErkJggg=='; var linkifyPastebin = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAF1QTFRFAAAAMzMzLi5GZGx0BQUFXmhxJScqOz1DXWNoa3F3a290k5melJaara+xlpeY8PHx4eLi29zc1dbWz8/Px8fHv7+/t7e4rq+vpqepnZ6ejo6OhISEfX5+d3d3YGBgwIwa3gAAAA90Uk5TAAULITE2YX6anqrW3vD+JUDspAAAAK5JREFUeNpNz0luwzAMBVA5ieMOiQbOouTc/5il2kXzueLDBwimlC77f66xb99WpVrTZpBtT+naSTKxeXHs/hmgnSd1PdvJw78CWitQsFBlEFyAowzM7hadX2i5ZNap2ZvDApCqTao0RaUFNHDwhKEes4BHl+5MJ0/JC2SKo4NVo6xHgJ06OCiOw+MWMPUlL+pdS33et7eGw/O4pHg2I0KtpdjjHntk//jLcdtSSj+AHw++3ZKWJwAAAABJRU5ErkJggg=='; var linkifyPeertube = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAMBQTFRFAAAA/wAA/1UA/2YAxlUcdHR0Lhcu62IUKioqcHBwFBQUdnZ2GxIb8WoH82cGc3l5c3NzEhge82gGHBwc9GcFHhkZcXFxJBwZc3Nz8WcFeXNuGxsb8mkFdHR08GkFVi0TlnBZ7mgIHhwecHBwHBoadHR0c3NzGhga8WgFGxkZc3Nz8WcFc3Nz8WgFc3NzGRga8WgFc3N08WkFGxka8WgFdHR0GxkadHR0GxoadHR0GhgZ8WkE8WgE8WgFdHR0GxkaZ7hqxgAAAD10Uk5TAAEDBQkLCw0YGRkcHCQqKiorLC4vMzRbXV5fYGFjZnZ5hYmLkpadnZ+hoqPU1dXV1tjZ2dzi4uPj8/P09Vap/bMAAACRSURBVHjaVc/XEoIwEAXQjYqKGrFXLNg7ILGb5P//ys1IhmQfz869swtQWXUJmFO7h+OiBU/53raJBVIm/YIN4SzomJF9k7KzX9KQYCll/HOsEwXlBTYq4Dzy8wju0tNwHeYQqo944ij47lq4USBeG4+yaIqlKQgRzwNcG3Aa/Q/TkUMDIIPbZeCAMe66Z77/A+q6ElNsGDWuAAAAAElFTkSuQmCC'; var linkifySoundcloud = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAMBQTFRF/2cA/2YA/2YA/2YA/2YA/2YA/2YA/////vby/eXW/N3K/NvJ/NnD/NjI/c+6+8uq/MKr+sKi+r6g+rme/LWY+qZ6+aNv/5Ux/48v/4ss/oos/Yos/4ol/Ikr+IFU/oMp/IIo94Iz9n88+XlK+IAo/n0l/Hwk9X0s93ot+Xok/XUh+nMg9m889HEg92k2+msc+2Qa/2YA/mYA/WUA+WAY91wj+lQU+00O+E0O/0kD/UcK+0cK+UYK/kEH/EAH/jwEfj1KkQAAAAd0Uk5TSFrk5+rt9gISbbcAAACaSURBVHjaTcE5igJRFIbR779v6K4OTaTNxFAQdP+LEMElOOEQiGBJvUHRoDxHClN6605h2NELe813fPv3CCEDoFTwRBAegESVVxSONm5yHDRkPEHky+0n1fY4LiY3ku1PKi5B6awxQ9ssIYDrLmD4h+wVgAKGRYCPxvBoUs+Dw/AO2F9Fi18B4q3Sepzo1WrLpC9pKdyM3io/AdrDMCk60CjnAAAAAElFTkSuQmCC'; var linkifyStreamable = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAGBQTFRFAAAAAID/CI/4gsr/BpH5CJH5BpL5arz7BZL6Z7r8BpP7BZH6BpH6JZ76B5L6BpH6BZD6/v//9Pr/5fP+2O7+wOL+veH9rtr9p9f9a7z8Y7j8T7D8O6f7JJ37GJj7BZH6dIRsvAAAABF0Uk5TAAYiKyyAiJCTlK6+0dfy+fzQvCOfAAAAmUlEQVR42l2PbQ+CMAyEC8KciBsbUxl7uf//L21HoobnQ5Neer2WhH6cjZlVTwfdaLGAsapr/YQvkygj/lDstwAKmFyB5SIDOTifKpcduNIMrGHf6muNwRc8yCC7DCQuxe0wTUhAeANVhGZZ49OXWDZf2aKA5Hlpds5H4NZiqySWVFqsjPzQp9PvxzPKorFo6YVBy/t6ICL6AH4DE9XqS58KAAAAAElFTkSuQmCC'; var linkifyTwitchtv = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IArs4c6QAAABVQTFRF////Zz6lZEGlY0KlZUKkZEGlZEGltJNrJQAAAAZ0Uk5TASVPXWV9PfYTqwAAAD5JREFUeNpjYEiDAAZmGIMtjQEEUBksCW74GBi6WGGMsEQgIy0BiAXAjLS0RAYQIw0kwABiJjEwQBgKDAwMAD4aF9kc7HdHAAAAAElFTkSuQmCC'; var linkifyX = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAAAAAA6mKC9AAAAAXNSR0IArs4c6QAAALNJREFUeNpjQAKMUFpQSIoJwudlB1NzLy47yMXEwMRgdFASrEJmJ0/vTAYWBpXThlCd8QsYTngx8B8IYwBrZWBiWBKqeEZ2SzoDM8x0keOS8a9LwPJQJT5rGXaDNUABC8OWMpGLEowIFfYHLyuHrYApYWTQPKFsdphhSQZMhOekDwNDcwfTKQ2w6xk5d8YzMDOxnbC33szMCNLQ2sPAxMDEYHh5wfMYsCZBqDd5pfl4sXgeAOR+JWx65NE5AAAAAElFTkSuQmCC'; var linkifyVideo = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAXNSR0IArs4c6QAAACxJREFUeNpj+M+AHzIQVEBYxX98JFEKSHPlf8IK/hNW8J+AAlLdMCgCihAEAJBiCAfi8KAsAAAAAElFTkSuQmCC'; var linkifyVidlii = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAL1QTFRFAAAAAFX/AAAqAFv/ABxVAEr/ACeFAFf/AB1NAFb/ADCbAFD/A1v/AFT/AD68ADqzAEniAln/AD69AEPEBVj6AD7BAkjSD2L/CVHeD2L/GWj8EVvnMXb/GmbzKnH8KHH8U4n/Qn/6GGb9NHf8WIz4Zpf+Zpf2b576//7+//78+/r59Pb97PH/7e/34Or/5On22OP/2eP2ytj6wdL5ssn/tMf4qMH/nbr8mLb4kbP6g639daP/dqL3SIP+KnL+KsPlKgAAACh0Uk5TAAYGDhIfLjU1PkBDVFhfYWl2d36Rk6CmsLjFx9nZ3unu8PL5+/39/kamH9cAAADJSURBVHjaHcjLUoNAEAXQ248hQ0gCxIVW8f8f5d4yVlyICwTm0W0lZ3mItMFWDwNjXgFAh05p/jlf+sP7MzhO3bUNMiyOEAiQyGS2xVM+5lPHOzSny9405H6ffpvCldei1Mfg8vaVYQa23CC1avT5ilAdWtJ3myfMPO3gCjC2rNVuxjC/l0ekorSLM6rkZ1g+UBkfQW4EgUuJ1UsrK9dwbBTYSlg3yK0r516jABRfEvFSi49CHwTgOqpbWlxAtimAPyHPayKAzf4BWmNnf7ZivjUAAAAASUVORK5CYII='; var linkifyVimeo = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAYFBMVEUAAAAIdZUKh6sLlLkLmr4LmsAMp88NrdYVW3MZj7Acstkrt9s1e5E7vN5EfI9JvdtKwuBijp5kpbl30eiDt8aG1uqRr7qTyNehxM+k4PCy3enB3OTg6Ovv9PXw+fz////L9U5WAAAAAXRSTlMAQObYZgAAAIFJREFUeNplz90OwiAMBWAQpAoyxclkP3je/y0H2AQXz0WT8100rRD6kNI9/cRroemQL3hXhoujZYj4OHoAmBvYGcBISwbWBvfXCrytnIDUQMkbsBpagMA7zhtQdyTFQAmIG7IkYniiZuh3XGsPqoOZkMOJOpAcLqUzNFGGu/57fwc1hgtp0mVSyQAAAABJRU5ErkJggg=='; var linkifyVine = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAF1QTFRFAAAAAKqqAMaOAL+VALyUALmNAMGTALySAL6RAL2OALyNAMCPAL+PALyPAL2PAL+QALyQAL2QAL6PAL+PAL+QAL6PAL+QAL+PAL6PAL6PAL6QAL6PAL6PAL6PAL6PbycyeQAAAB50Uk5TAAMJDBMdISozNkFJUFttd36ImKqvtL/Iz9zn7/b6+V1hqgAAAKZJREFUeNotyEF2gzAMQMFvyZZJ0sf9zwkpNRhJ3TDLKTzeP8b1PagFEuirwVLmrB/jGPCyOPRly6zyKfdAlHszU0U8UChCZEIiM6hKOCpaMlCsMZzWxQf3EZrWy7wIq5VxRirS1QdBa315cyrZa7mciQlcZ+Uevb2uRCT3P0chu8q8l1XPbSYKLl2Jtd/bCSjgtdXFcv/lifDWNL978gTuWo49APgHoehXKFGk4JYAAAAASUVORK5CYII='; var linkifyVocaroo = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAYBQTFRFAAAAAAAAAFUAJ04UFDsAKFENTnosIU4LO1gUM1URN2ccRm4dOmodaZI2P2seK2ULeZ9MO24VYIgtXIorPXMUI1oIYY0vS40kg65KhaJuZIg+epxVm8ZZYIswNXUPSXIhQ4AaPYQSPYIQpNRlUIAlg6xJTHMyjLpJeKRFmcZbcqA3ksJQPIUNcKA8osCClcRZZ5Mzcp4/m8RjpdRljrtgXp4sRpUSXKcpYqMwk8JVrdtpaKsxga1GjbtVVqcfjbtNocxngrpEt+Zyab02vu53pdFkXqwqotJilcZUzPKTrNV2tb1mq9hnqdlZUKMX7u7uzf6CzP2By/yAzfeOyPd+xfR8wfB6v+52uuhzweJ1tuVvx8fHtOFvsuBrwddyt9iEseJesNtvrdxkpNJhv8JsnNdboNFfotFYpcxposJxm8dco7aFm8BjvatpiMtOkMJOmbFyiMFKlbNlvZ1ghb1Afb5BjqxffJJbe4doUagXxWFTzEVR1zZP4i9QW2hGWmFP8o8FIgAAAE90Uk5TAAIGDQ0TFxcaHiUsNT1FR0pKWmRkZmdpa2trb3V1eH2IjZOVlpqdnp+ipqqtsrO7ws3O2dra2tzd4OHj5ujo7e/x8/P1+Pj6+/z8/P39/a8ykJsAAADdSURBVHjaY4AARg4VXU1tMUYGMGDnFzByjYmISvLlYwADUTWvsAAgiErQYgILSHoGgEFgvp8OWJMeiBdaHh+QVmkGUsJkkliRGZATXR8QXiICUsCTV+RfFxCaXRYQmC4IEmA1DSvICI8NBeqzYQfxuZitgDYkhQQ5aXIC+dK2Fiy8xj7uqaUG8mBncPI4KzFyaBiWFvpkeYCNYBB3UzDPCQ5KiUu2dhMGCTA5OCpK5VbXVkWo61uCncrNxcDtXVNbHGRvJ8cABUxWkcWhLoYesgwwIKEsI8DGpCrEAABv9i67kptWHQAAAABJRU5ErkJggg=='; var linkifyYoutube = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IArs4c6QAAADBQTFRFAAAA/wAA/wAA/wAA/wAA/wAA/wAA/wAA/////+Dg/8DA/7Cw/1BQ/0BA/xsb/wAABceraAAAAAh0Uk5TABAwQFBgcIDN7a/fAAAAS0lEQVR42mPAAoTNy8uLDRkYWP+DQQCDPoTxicEfwvjKUP///8/7//9/BzF+dL+HMrqgjJ/n////DlL8D6QYrh1hIIOwGcgKLHYDAI8wSVRb1KCAAAAAAElFTkSuQmCC'; var variableBase = `:root{--xt-background:initial;--xt-border:initial;--xt-border-field-focus:initial;--xt-border-highlight:initial;--xt-catalog-border-style-filter:initial;--xt-catalog-border-style-own:initial;--xt-catalog-border-style-watched:initial;--xt-catalog-border-width:initial;--xt-catalog-border-width-filter:initial;--xt-catalog-border-width-own:initial;--xt-catalog-border-width-watched:initial;--xt-catalog-filter-dead-link:initial;--xt-catalog-filter-glow-intensity:initial;--xt-catalog-filter-highlight:initial;--xt-catalog-filter-highlight-opacity:initial;--xt-catalog-filter-link:initial;--xt-catalog-filter-quote:initial;--xt-catalog-filter-subject:initial;--xt-catalog-filter-text:initial;--xt-catalog-own-dead-link:initial;--xt-catalog-own-glow-intensity:initial;--xt-catalog-own-highlight:initial;--xt-catalog-own-highlight-opacity:initial;--xt-catalog-own-link:initial;--xt-catalog-own-quote:initial;--xt-catalog-own-subject:initial;--xt-catalog-own-text:initial;--xt-catalog-watched-dead-link:initial;--xt-catalog-watched-glow-intensity:initial;--xt-catalog-watched-highlight:initial;--xt-catalog-watched-highlight-opacity:initial;--xt-catalog-watched-link:initial;--xt-catalog-watched-quote:initial;--xt-catalog-watched-subject:initial;--xt-catalog-watched-text:initial;--xt-dead-link:initial;--xt-dead-link-text-color:initial;--xt-edge-style-ghost:initial;--xt-edge-style-own:initial;--xt-edge-style-you:initial;--xt-edge-width-ghost:initial;--xt-edge-width-own:initial;--xt-edge-width-you:initial;--xt-entry-focus-bg:initial;--xt-entry-size:initial;--xt-filter-highlight:initial;--xt-header-dialog-bg:initial;--xt-header-dialog-fg:initial;--xt-header-link:initial;--xt-highlight-edge-width:initial;--xt-highlight-ghost:initial;--xt-highlight-ghost-dead-link:initial;--xt-highlight-ghost-link:initial;--xt-highlight-ghost-opacity:initial;--xt-highlight-ghost-quote:initial;--xt-highlight-ghost-text:initial;--xt-highlight-own:initial;--xt-highlight-own-dead-link:initial;--xt-highlight-own-link:initial;--xt-highlight-own-opacity:initial;--xt-highlight-own-quote:initial;--xt-highlight-own-text:initial;--xt-highlight-shadow:initial;--xt-highlight-side-arrow:initial;--xt-highlight-you:initial;--xt-highlight-you-dead-link:initial;--xt-highlight-you-link:initial;--xt-highlight-you-opacity:initial;--xt-highlight-you-quote:initial;--xt-highlight-you-text:initial;--xt-inline:initial;--xt-link-hover-bg:initial;--xt-link-text-color:initial;--xt-menu-fg:initial;--xt-notification-size:initial;--xt-post-background:initial;--xt-qphl:initial;--xt-qr-bg:initial;--xt-qr-link-border:initial;--xt-qr-preview-bg:initial;--xt-qr-sampled-bg:initial;--xt-qr-sampled-border:initial;--xt-qr-sampled-fg:initial;--xt-quote-text-color:initial;--xt-text-color:initial;--xt-unread:initial;--xt-unread-line:initial;--xt-watched-border:initial;--xt-watcher-quoting-you:initial}.dialog{background-color:var(--xt-background);border-color:var(--xt-border)}.field:focus,.field.focus{border-color:var(--xt-border-field-focus)}:root:not(.oneechan).highlight-you.xt-edge-you .quotesYou__xt-site-highlightable-reply{border-left:var(--xt-edge-width-you,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-you,solid) var(--xt-border-highlight) !important}:root:not(.oneechan).highlight-own.xt-edge-own .yourPost__xt-site-highlightable-reply{border-left:var(--xt-edge-width-own,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-own,dashed) var(--xt-border-highlight) !important}#header-bar.dialog{background-color:var(--xt-header-dialog-bg)}:root:not(.fixed) #header-bar,#notifications{font-size:var(--xt-notification-size,9pt)}#header-bar,#notifications{color:var(--xt-header-dialog-fg)}#board-list a,#shortcuts a{color:var(--xt-header-link,unset)}.section-main div::before,.section-main div::after{border-color:var(--xt-border)}:root.catalog-hover-expand .catalog-container:hover>.post{background-color:var(--xt-background)}:root.werkTyme .catalog-thread:not(:hover),:root.werkTyme:not(.catalog-hover-expand) .catalog-thread,:root.catalog-hover-expand .catalog-container:hover>.post,:root.catalog-hover-expand .catalog-container:hover .catalog-reply{border-color:var(--xt-border)}.backlink.deadlink{color:var(--xt-dead-link) !important}.inline{border-color:var(--xt-border);background-color:var(--xt-inline,rgba(255,255,255,.14))}:root.xt-custom-text-colors .post,:root.xt-custom-text-colors .postInfo,:root.xt-custom-text-colors .postMessage,:root.xt-custom-text-colors .summary,:root.xt-custom-text-colors .fileText,:root.xt-custom-text-colors .fileText a{color:var(--xt-text-color)}:root.xt-custom-text-colors .post a,:root.xt-custom-text-colors .summary a,:root.xt-custom-text-colors #arc-list a{color:var(--xt-link-text-color)}:root.xt-custom-text-colors .post .quote,:root.xt-custom-text-colors #qr-com-preview .quote,:root.xt-custom-text-colors #arc-list .quote{color:var(--xt-quote-text-color) !important}:root.xt-custom-text-colors .backlink.deadlink,:root.xt-custom-text-colors .quotelink.deadlink{color:var(--xt-dead-link-text-color,var(--xt-dead-link)) !important}:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-op,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-reply{color:var(--xt-highlight-you-text,var(--xt-text-color))}:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-op .postInfo,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-reply .postInfo,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-op .postMessage,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-reply .postMessage,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-op .summary,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-reply .summary,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-op .fileText,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-reply .fileText{color:var(--xt-highlight-you-text,var(--xt-text-color))}:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-op a,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-reply a{color:var(--xt-highlight-you-link,var(--xt-link-text-color)) !important}:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-op .quote,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-reply .quote{color:var(--xt-highlight-you-quote,var(--xt-quote-text-color)) !important}:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-op .backlink.deadlink,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-reply .backlink.deadlink,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-op .quotelink.deadlink,:root.xt-custom-text-colors .quotesYou__xt-site-highlightable-reply .quotelink.deadlink{color:var(--xt-highlight-you-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}:root.xt-custom-text-colors .yourPost__xt-site-highlightable-op,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-reply{color:var(--xt-highlight-own-text,var(--xt-text-color))}:root.xt-custom-text-colors .yourPost__xt-site-highlightable-op .postInfo,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-reply .postInfo,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-op .postMessage,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-reply .postMessage,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-op .summary,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-reply .summary,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-op .fileText,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-reply .fileText{color:var(--xt-highlight-own-text,var(--xt-text-color))}:root.xt-custom-text-colors .yourPost__xt-site-highlightable-op a,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-reply a{color:var(--xt-highlight-own-link,var(--xt-link-text-color)) !important}:root.xt-custom-text-colors .yourPost__xt-site-highlightable-op .quote,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-reply .quote{color:var(--xt-highlight-own-quote,var(--xt-quote-text-color)) !important}:root.xt-custom-text-colors .yourPost__xt-site-highlightable-op .backlink.deadlink,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-reply .backlink.deadlink,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-op .quotelink.deadlink,:root.xt-custom-text-colors .yourPost__xt-site-highlightable-reply .quotelink.deadlink{color:var(--xt-highlight-own-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}:root.xt-custom-text-colors .from-archive__xt-site-highlightable-op,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-reply{color:var(--xt-highlight-ghost-text,var(--xt-text-color))}:root.xt-custom-text-colors .from-archive__xt-site-highlightable-op .postInfo,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-reply .postInfo,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-op .postMessage,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-reply .postMessage,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-op .summary,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-reply .summary,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-op .fileText,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-reply .fileText{color:var(--xt-highlight-ghost-text,var(--xt-text-color))}:root.xt-custom-text-colors .from-archive__xt-site-highlightable-op a,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-reply a{color:var(--xt-highlight-ghost-link,var(--xt-link-text-color)) !important}:root.xt-custom-text-colors .from-archive__xt-site-highlightable-op .quote,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-reply .quote{color:var(--xt-highlight-ghost-quote,var(--xt-quote-text-color)) !important}:root.xt-custom-text-colors .from-archive__xt-site-highlightable-op .backlink.deadlink,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-reply .backlink.deadlink,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-op .quotelink.deadlink,:root.xt-custom-text-colors .from-archive__xt-site-highlightable-reply .quotelink.deadlink{color:var(--xt-highlight-ghost-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}.indicator{color:var(--xt-background)}:root:not(.xt-external-style) #dump-list::-webkit-scrollbar-thumb{background-color:rgba(128,128,128,.5)}.qr-preview{background-color:var(--xt-qr-preview-bg,rgba(0,0,0,.15))}.qr-link{border-color:var(--xt-qr-link-border);background:var(--xt-qr-bg)}.qr-link:hover{background:var(--xt-link-hover-bg,var(--xt-background))}#menu{color:var(--xt-menu-fg,var(--xt-header-link))}.entry{font-size:var(--xt-entry-size,10pt)}.focused.entry{background:var(--xt-entry-focus-bg)}.unread-mark-read{background-color:var(--xt-unread)}.replies-quoting-you>a,#watcher-link.replies-quoting-you,.last-page>a>.watcher-page{color:var(--xt-watcher-quoting-you,#F00) !important}.qphl{outline:2px solid var(--xt-qphl,rgba(216,94,49,.8))}:root.highlight-you.xt-edge-you .quotesYou__xt-site-highlightable-op,:root.highlight-you.xt-edge-you .quotesYou__xt-site-highlightable-reply{border-left:var(--xt-edge-width-you,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-you,solid) color-mix(in srgb,var(--xt-highlight-you,var(--xt-border-highlight)) calc(var(--xt-highlight-you-opacity,1) * 100%),transparent)}:root.highlight-you:not(.xt-edge-you) .quotesYou__xt-site-highlightable-op,:root.highlight-you:not(.xt-edge-you) .quotesYou__xt-site-highlightable-reply{background-color:color-mix(in srgb,var(--xt-highlight-you,var(--xt-border-highlight)) calc(var(--xt-highlight-you-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,transparent)))}:root.highlight-own.xt-edge-own .yourPost__xt-site-highlightable-op,:root.highlight-own.xt-edge-own .yourPost__xt-site-highlightable-reply{border-left:var(--xt-edge-width-own,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-own,dashed) color-mix(in srgb,var(--xt-highlight-own,var(--xt-border-highlight)) calc(var(--xt-highlight-own-opacity,1) * 100%),transparent)}:root.highlight-own:not(.xt-edge-own) .yourPost__xt-site-highlightable-op,:root.highlight-own:not(.xt-edge-own) .yourPost__xt-site-highlightable-reply{background-color:color-mix(in srgb,var(--xt-highlight-own,var(--xt-border-highlight)) calc(var(--xt-highlight-own-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,transparent)))}:root.highlight-ghost.xt-edge-ghost .from-archive__xt-site-highlightable-op,:root.highlight-ghost.xt-edge-ghost .from-archive__xt-site-highlightable-reply{border-left:var(--xt-edge-width-ghost,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-ghost,dotted) color-mix(in srgb,var(--xt-highlight-ghost,#888) calc(var(--xt-highlight-ghost-opacity,1) * 100%),transparent)}:root.highlight-ghost:not(.xt-edge-ghost) .from-archive__xt-site-highlightable-op,:root.highlight-ghost:not(.xt-edge-ghost) .from-archive__xt-site-highlightable-reply{background-color:color-mix(in srgb,var(--xt-highlight-ghost,#888) calc(var(--xt-highlight-ghost-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,transparent)))}:root.xt-set-you-highlight.highlight-you.xt-edge-you .quotesYou__xt-site-highlightable-op,:root.xt-set-you-highlight.highlight-you.xt-edge-you .quotesYou__xt-site-highlightable-reply{border-left:var(--xt-edge-width-you,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-you,solid) color-mix(in srgb,var(--xt-highlight-you) calc(var(--xt-highlight-you-opacity,1) * 100%),transparent) !important}:root.xt-set-you-highlight.highlight-you:not(.xt-edge-you) .quotesYou__xt-site-highlightable-op,:root.xt-set-you-highlight.highlight-you:not(.xt-edge-you) .quotesYou__xt-site-highlightable-reply{background-color:color-mix(in srgb,var(--xt-highlight-you) calc(var(--xt-highlight-you-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,transparent))) !important}:root.xt-set-own-highlight.highlight-own.xt-edge-own .yourPost__xt-site-highlightable-op,:root.xt-set-own-highlight.highlight-own.xt-edge-own .yourPost__xt-site-highlightable-reply{border-left:var(--xt-edge-width-own,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-own,dashed) color-mix(in srgb,var(--xt-highlight-own) calc(var(--xt-highlight-own-opacity,1) * 100%),transparent) !important}:root.xt-set-own-highlight.highlight-own:not(.xt-edge-own) .yourPost__xt-site-highlightable-op,:root.xt-set-own-highlight.highlight-own:not(.xt-edge-own) .yourPost__xt-site-highlightable-reply{background-color:color-mix(in srgb,var(--xt-highlight-own) calc(var(--xt-highlight-own-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,transparent))) !important}:root.xt-set-ghost-highlight.highlight-ghost.xt-edge-ghost .from-archive__xt-site-highlightable-op,:root.xt-set-ghost-highlight.highlight-ghost.xt-edge-ghost .from-archive__xt-site-highlightable-reply{border-left:var(--xt-edge-width-ghost,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-ghost,dotted) color-mix(in srgb,var(--xt-highlight-ghost) calc(var(--xt-highlight-ghost-opacity,1) * 100%),transparent) !important}:root.xt-set-ghost-highlight.highlight-ghost:not(.xt-edge-ghost) .from-archive__xt-site-highlightable-op,:root.xt-set-ghost-highlight.highlight-ghost:not(.xt-edge-ghost) .from-archive__xt-site-highlightable-reply{background-color:color-mix(in srgb,var(--xt-highlight-ghost) calc(var(--xt-highlight-ghost-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,transparent))) !important}.filter-highlight__xt-site-highlightable-op,.filter-highlight__xt-site-highlightable-reply{box-shadow:inset 5px 0 var(--xt-filter-highlight,rgba(221,0,0,.5))}:root.highlight-own .yourPost>__xt-site-sideArrows,:root.highlight-you .quotesYou>__xt-site-sideArrows,.filter-highlight>__xt-site-sideArrows{color:var(--xt-highlight-side-arrow,rgba(221,0,0,.8))}:root:not(.werkTyme) .catalog-thread.filter-highlight:not(.filter-glow-tile) .catalog-thumb,:root.werkTyme .catalog-thread.filter-highlight:not(:hover),:root.werkTyme:not(.catalog-hover-expand) .catalog-thread.filter-highlight,:root.werkTyme.catalog-hover-expand .catalog-thread.filter-highlight>.catalog-container:hover>.catalog-post,:root.catalog __xt-site-catalog-thread.filter-highlight:not(.filter-glow-tile)__xt-site-highlightable-catalog{box-shadow:0 0 calc(var(--xt-catalog-filter-glow-intensity,.5) * 6px) calc(var(--xt-catalog-filter-glow-intensity,.5) * 6px) var(--xt-catalog-filter-highlight,var(--xt-highlight-shadow,rgba(255,0,0,.5)))}:root:not(.werkTyme) .catalog-thread.filter-highlight.filter-glow-tile{box-shadow:0 0 calc(var(--xt-catalog-filter-glow-intensity,.5) * 6px) calc(var(--xt-catalog-filter-glow-intensity,.5) * 6px) var(--xt-catalog-filter-highlight,var(--xt-highlight-shadow,rgba(255,0,0,.5)))}:root.xt-catalog-filter-glow-off .catalog-thread.filter-highlight,:root.xt-catalog-filter-glow-off .catalog-thread.filter-highlight .catalog-thumb{box-shadow:none !important}:root.xt-set-catalog-filter-highlight:not(.xt-catalog-edge-filter):not(.xt-catalog-filter-loc-image) .catalog-thread.filter-highlight{background:color-mix(in srgb,var(--xt-catalog-filter-highlight,var(--xt-highlight-shadow,rgba(255,0,0,.75))) calc(var(--xt-catalog-filter-highlight-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,#d6daf0))) !important}:root.xt-set-catalog-filter-highlight.xt-catalog-edge-filter:not(.xt-catalog-filter-loc-image) .catalog-thread.filter-highlight{border-color:color-mix(in srgb,var(--xt-catalog-filter-highlight,var(--xt-highlight-shadow,rgba(255,0,0,.75))) calc(var(--xt-catalog-filter-highlight-opacity,1) * 100%),transparent) !important;border-style:var(--xt-catalog-border-style-filter,solid) !important;border-width:var(--xt-catalog-border-width-filter,var(--xt-catalog-border-width,3px)) !important}:root.xt-set-catalog-filter-highlight.xt-catalog-filter-loc-image.xt-catalog-edge-filter .catalog-thread.filter-highlight .catalog-thumb{border:var(--xt-catalog-border-width-filter,var(--xt-catalog-border-width,3px)) var(--xt-catalog-border-style-filter,solid) color-mix(in srgb,var(--xt-catalog-filter-highlight,var(--xt-highlight-shadow,rgba(255,0,0,.75))) calc(var(--xt-catalog-filter-highlight-opacity,1) * 100%),transparent) !important;box-sizing:border-box}:root.xt-set-catalog-filter-highlight.xt-catalog-filter-loc-image:not(.xt-catalog-edge-filter) .catalog-thread.filter-highlight .catalog-thumb{background:color-mix(in srgb,var(--xt-catalog-filter-highlight,var(--xt-highlight-shadow,rgba(255,0,0,.75))) calc(var(--xt-catalog-filter-highlight-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,#d6daf0))) !important;box-sizing:border-box;padding:var(--xt-catalog-border-width-filter,var(--xt-catalog-border-width,3px)) !important}:root.xt-set-catalog-filter-highlight:not(.xt-catalog-edge-filter):not(.xt-catalog-filter-loc-image) .catalog-thread.filter-highlight>.catalog-container,:root.xt-set-catalog-filter-highlight:not(.xt-catalog-edge-filter):not(.xt-catalog-filter-loc-image) .catalog-thread.filter-highlight .post.catalog-post{background:transparent !important;border-color:transparent !important}:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own):not(.xt-catalog-own-loc-image) .catalog-thread:has(.yourPost),:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own):not(.xt-catalog-own-loc-image) .catalog-thread.yourPost{background:color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own)) calc(var(--xt-catalog-own-highlight-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,#d6daf0))) !important}:root.xt-highlight-catalog-own.xt-catalog-edge-own:not(.xt-catalog-own-loc-image) .catalog-thread:has(.yourPost),:root.xt-highlight-catalog-own.xt-catalog-edge-own:not(.xt-catalog-own-loc-image) .catalog-thread.yourPost{border-color:color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own,var(--xt-border-highlight))) calc(var(--xt-catalog-own-highlight-opacity,1) * 100%),transparent) !important;border-style:var(--xt-catalog-border-style-own,solid) !important;border-width:var(--xt-catalog-border-width-own,var(--xt-catalog-border-width,3px)) !important}:root.xt-highlight-catalog-own.xt-catalog-own-loc-image.xt-catalog-edge-own .catalog-thread:has(.yourPost) .catalog-thumb,:root.xt-highlight-catalog-own.xt-catalog-own-loc-image.xt-catalog-edge-own .catalog-thread.yourPost .catalog-thumb{border:var(--xt-catalog-border-width-own,var(--xt-catalog-border-width,3px)) var(--xt-catalog-border-style-own,solid) color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own,var(--xt-border-highlight))) calc(var(--xt-catalog-own-highlight-opacity,1) * 100%),transparent) !important;box-sizing:border-box}:root.xt-highlight-catalog-own.xt-catalog-own-loc-image:not(.xt-catalog-edge-own) .catalog-thread:has(.yourPost) .catalog-thumb,:root.xt-highlight-catalog-own.xt-catalog-own-loc-image:not(.xt-catalog-edge-own) .catalog-thread.yourPost .catalog-thumb{background:color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own)) calc(var(--xt-catalog-own-highlight-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,#d6daf0))) !important;box-sizing:border-box;padding:var(--xt-catalog-border-width-own,var(--xt-catalog-border-width,3px)) !important}:root:not(.catalog-hover-expand).xt-highlight-catalog-own:not(.xt-catalog-own-loc-image) .catalog-thread:has(.yourPost),:root:not(.catalog-hover-expand).xt-highlight-catalog-own:not(.xt-catalog-own-loc-image) .catalog-thread.yourPost{overflow:hidden}:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread:has(.yourPost)>.catalog-container,:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread.yourPost>.catalog-container,:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread>.catalog-container.yourPost,:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread:has(.yourPost) .post.catalog-post.yourPost,:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread.yourPost .post.catalog-post,:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread>.catalog-container.yourPost .post.catalog-post{background:transparent !important;border-color:transparent !important}:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread:has(.yourPost) .post.catalog-post.yourPost,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread.yourPost .post.catalog-post,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread>.catalog-container.yourPost .post.catalog-post{color:var(--xt-catalog-own-text,var(--xt-text-color)) !important}:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread:has(.yourPost) .post.catalog-post.yourPost .postInfo,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread:has(.yourPost) .post.catalog-post.yourPost .postMessage,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread:has(.yourPost) .post.catalog-post.yourPost .summary,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread:has(.yourPost) .post.catalog-post.yourPost .fileText,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread:has(.yourPost) .post.catalog-post.yourPost .catalog-stats,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread.yourPost .post.catalog-post .postInfo,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread.yourPost .post.catalog-post .postMessage,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread.yourPost .post.catalog-post .summary,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread.yourPost .post.catalog-post .fileText,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread.yourPost .post.catalog-post .catalog-stats,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread>.catalog-container.yourPost .post.catalog-post .postInfo,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread>.catalog-container.yourPost .post.catalog-post .postMessage,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread>.catalog-container.yourPost .post.catalog-post .summary,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread>.catalog-container.yourPost .post.catalog-post .fileText,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread>.catalog-container.yourPost .post.catalog-post .catalog-stats{color:var(--xt-catalog-own-text,var(--xt-text-color)) !important}:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread:has(.yourPost) .post.catalog-post.yourPost a,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread.yourPost .post.catalog-post a,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread>.catalog-container.yourPost .post.catalog-post a{color:var(--xt-catalog-own-link,var(--xt-link-text-color)) !important}:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread:has(.yourPost) .post.catalog-post.yourPost .quote,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread.yourPost .post.catalog-post .quote,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread>.catalog-container.yourPost .post.catalog-post .quote{color:var(--xt-catalog-own-quote,var(--xt-quote-text-color)) !important}:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread:has(.yourPost) .post.catalog-post.yourPost .subject,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread.yourPost .post.catalog-post .subject,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread>.catalog-container.yourPost .post.catalog-post .subject{color:var(--xt-catalog-own-subject,var(--xt-catalog-own-text,var(--xt-text-color))) !important}:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread:has(.yourPost) .post.catalog-post.yourPost .deadlink,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread.yourPost .post.catalog-post .deadlink,:root.xt-highlight-catalog-own.xt-catalog-own-text-colors .catalog-thread>.catalog-container.yourPost .post.catalog-post .deadlink{color:var(--xt-catalog-own-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread:has(.yourPost) .post.catalog-post.yourPost>*,:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread:has(.yourPost) .post.catalog-post.yourPost .postMessage,:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread.yourPost .post.catalog-post>*,:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread.yourPost .post.catalog-post .postMessage,:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread>.catalog-container.yourPost .post.catalog-post>*,:root.xt-highlight-catalog-own:not(.xt-catalog-edge-own) .catalog-thread>.catalog-container.yourPost .post.catalog-post .postMessage{background:transparent !important}:root.xt-highlight-catalog-watched:not(.xt-catalog-edge-watched):not(.xt-catalog-watched-loc-image) .catalog-thread.watched:not(:has(.yourPost)){background:color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) calc(var(--xt-catalog-watched-highlight-opacity,0.2) * 100%),var(--xt-post-background,var(--xt-background,#d6daf0))) !important}:root.xt-highlight-catalog-watched.xt-catalog-edge-watched:not(.xt-catalog-watched-loc-image) .catalog-thread.watched:not(:has(.yourPost)){border-color:color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) calc(var(--xt-catalog-watched-highlight-opacity,0.2) * 100%),transparent) !important;border-style:var(--xt-catalog-border-style-watched,solid) !important;border-width:var(--xt-catalog-border-width-watched,var(--xt-catalog-border-width,3px)) !important}:root.xt-highlight-catalog-watched.xt-catalog-watched-loc-image.xt-catalog-edge-watched .catalog-thread.watched:not(:has(.yourPost)) .catalog-thumb{border:var(--xt-catalog-border-width-watched,var(--xt-catalog-border-width,3px)) var(--xt-catalog-border-style-watched,solid) color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) calc(var(--xt-catalog-watched-highlight-opacity,0.2) * 100%),transparent) !important;box-sizing:border-box}:root.xt-highlight-catalog-watched.xt-catalog-watched-loc-image:not(.xt-catalog-edge-watched) .catalog-thread.watched:not(:has(.yourPost)) .catalog-thumb{background:color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) calc(var(--xt-catalog-watched-highlight-opacity,0.2) * 100%),var(--xt-post-background,var(--xt-background,#d6daf0))) !important;box-sizing:border-box;padding:var(--xt-catalog-border-width-watched,var(--xt-catalog-border-width,3px)) !important}:root:not(.catalog-hover-expand).xt-highlight-catalog-watched:not(.xt-catalog-watched-loc-image) .catalog-thread.watched:not(:has(.yourPost)){overflow:hidden}:root.xt-highlight-catalog-watched:not(.xt-catalog-edge-watched) .catalog-thread.watched:not(:has(.yourPost))>.catalog-container,:root.xt-highlight-catalog-watched:not(.xt-catalog-edge-watched) .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post{background:transparent !important;border-color:transparent !important}:root.xt-highlight-catalog-watched.xt-catalog-watched-text-colors .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post{color:var(--xt-catalog-watched-text,var(--xt-text-color)) !important}:root.xt-highlight-catalog-watched.xt-catalog-watched-text-colors .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post .postInfo,:root.xt-highlight-catalog-watched.xt-catalog-watched-text-colors .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post .postMessage,:root.xt-highlight-catalog-watched.xt-catalog-watched-text-colors .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post .summary,:root.xt-highlight-catalog-watched.xt-catalog-watched-text-colors .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post .fileText,:root.xt-highlight-catalog-watched.xt-catalog-watched-text-colors .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post .catalog-stats{color:var(--xt-catalog-watched-text,var(--xt-text-color)) !important}:root.xt-highlight-catalog-watched.xt-catalog-watched-text-colors .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post a{color:var(--xt-catalog-watched-link,var(--xt-link-text-color)) !important}:root.xt-highlight-catalog-watched.xt-catalog-watched-text-colors .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post .quote{color:var(--xt-catalog-watched-quote,var(--xt-quote-text-color)) !important}:root.xt-highlight-catalog-watched.xt-catalog-watched-text-colors .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post .subject{color:var(--xt-catalog-watched-subject,var(--xt-catalog-watched-text,var(--xt-text-color))) !important}:root.xt-highlight-catalog-watched.xt-catalog-watched-text-colors .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post .deadlink{color:var(--xt-catalog-watched-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}:root.xt-highlight-catalog-watched:not(.xt-catalog-edge-watched) .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post>*,:root.xt-highlight-catalog-watched:not(.xt-catalog-edge-watched) .catalog-thread.watched:not(:has(.yourPost)) .post.catalog-post .postMessage{background:transparent !important}:root.xt-highlight-catalog-filter.xt-catalog-filter-text-colors .catalog-thread.filter-highlight .post.catalog-post,:root.xt-highlight-catalog-filter.xt-catalog-filter-text-colors .catalog-thread.filter-highlight .post.catalog-post .postInfo,:root.xt-highlight-catalog-filter.xt-catalog-filter-text-colors .catalog-thread.filter-highlight .post.catalog-post .postMessage,:root.xt-highlight-catalog-filter.xt-catalog-filter-text-colors .catalog-thread.filter-highlight .post.catalog-post .summary,:root.xt-highlight-catalog-filter.xt-catalog-filter-text-colors .catalog-thread.filter-highlight .post.catalog-post .fileText,:root.xt-highlight-catalog-filter.xt-catalog-filter-text-colors .catalog-thread.filter-highlight .post.catalog-post .catalog-stats{color:var(--xt-catalog-filter-text,var(--xt-text-color)) !important}:root.xt-highlight-catalog-filter.xt-catalog-filter-text-colors .catalog-thread.filter-highlight .post.catalog-post a{color:var(--xt-catalog-filter-link,var(--xt-link-text-color)) !important}:root.xt-highlight-catalog-filter.xt-catalog-filter-text-colors .catalog-thread.filter-highlight .post.catalog-post .quote{color:var(--xt-catalog-filter-quote,var(--xt-quote-text-color)) !important}:root.xt-highlight-catalog-filter.xt-catalog-filter-text-colors .catalog-thread.filter-highlight .post.catalog-post .subject{color:var(--xt-catalog-filter-subject,var(--xt-catalog-filter-text,var(--xt-text-color))) !important}:root.xt-highlight-catalog-filter.xt-catalog-filter-text-colors .catalog-thread.filter-highlight .post.catalog-post .deadlink{color:var(--xt-catalog-filter-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}:root.xt-set-catalog-filter-highlight:not(.xt-catalog-edge-filter):not(.xt-catalog-filter-loc-image) .catalog-thread.filter-highlight .post.catalog-post>*,:root.xt-set-catalog-filter-highlight:not(.xt-catalog-edge-filter):not(.xt-catalog-filter-loc-image) .catalog-thread.filter-highlight .post.catalog-post .postMessage{background:transparent !important}:root.xt-highlight-catalog-own.xt-catalog-own-glow:not(.xt-catalog-own-loc-image) .catalog-thread:has(.yourPost),:root.xt-highlight-catalog-own.xt-catalog-own-glow:not(.xt-catalog-own-loc-image) .catalog-thread.yourPost{box-shadow:0 0 calc(var(--xt-catalog-own-glow-intensity,.5) * 6px) calc(var(--xt-catalog-own-glow-intensity,.5) * 6px) color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own)) 70%,transparent) !important}:root.xt-highlight-catalog-own.xt-catalog-own-glow.xt-catalog-own-loc-image .catalog-thread:has(.yourPost) .catalog-thumb,:root.xt-highlight-catalog-own.xt-catalog-own-glow.xt-catalog-own-loc-image .catalog-thread.yourPost .catalog-thumb{box-shadow:0 0 calc(var(--xt-catalog-own-glow-intensity,.5) * 6px) calc(var(--xt-catalog-own-glow-intensity,.5) * 6px) color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own)) 70%,transparent) !important}:root.xt-highlight-catalog-watched.xt-catalog-watched-glow:not(.xt-catalog-watched-loc-image) .catalog-thread.watched:not(:has(.yourPost)){box-shadow:0 0 calc(var(--xt-catalog-watched-glow-intensity,.5) * 6px) calc(var(--xt-catalog-watched-glow-intensity,.5) * 6px) color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) 70%,transparent) !important}:root.xt-highlight-catalog-watched.xt-catalog-watched-glow.xt-catalog-watched-loc-image .catalog-thread.watched:not(:has(.yourPost)) .catalog-thumb{box-shadow:0 0 calc(var(--xt-catalog-watched-glow-intensity,.5) * 6px) calc(var(--xt-catalog-watched-glow-intensity,.5) * 6px) color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) 70%,transparent) !important}.unread-line{border-color:var(--xt-unread-line,rgb(255,0,0))}:root.tomorrow:not(.xt-external-style),:root.spooky:not(.xt-external-style){color-scheme:dark}:root.tomorrow:not(.xt-external-style) .field,:root.spooky:not(.xt-external-style) .field{background-color:unset;color:unset;border-color:unset}:root.tomorrow:not(.xt-external-style) input[type="button"],:root.spooky:not(.xt-external-style) input[type="button"]{filter:brightness(80%)}`; var photon = `:root.photon{--xt-background:#DDD;--xt-border:#CCC;--xt-border-field-focus:#EA8;--xt-border-highlight:rgba(221,0,0,.8);--xt-filter-highlight:rgba(221,0,0,.5);--xt-highlight-shadow:rgba(221,0,0,.5);--xt-highlight-side-arrow:rgba(221,0,0,.8);--xt-watched-border:rgb(221,0,0);--xt-header-dialog-bg:rgba(221,221,221,0.98);--xt-header-dialog-fg:#333;--xt-quote-color:#C0E17A;--xt-dead-link:#F60;--xt-qr-link-border:rgb(206,206,206) rgb(206,206,206) rgb(191,191,191);--xt-qr-bg:linear-gradient(#ECECEC,#DDD) repeat scroll 0% 0% transparent;--xt-menu-fg:#333;--xt-entry-focus-bg:rgba(255,255,255,.33);--xt-unread:rgba(221,221,221,0.5);--xt-watcher-quoting-you:#00F;--xt-watcher:#333;--xt-fxt-fg:#333}:root.photon #arc-list tr:nth-of-type(odd) span.quote{color:#C0E17A}`; var report = `#g-recaptcha,:root:not(.js-enabled) #captchaContainerAlt{height:auto}#captchaContainerAlt td:nth-child(2){display:table-cell !important}#archive-report{padding:3px}#archive-report-enabled{vertical-align:middle}#archive-report>label{display:block}#archive-report-reason{display:block;width:98%}.archive-report-success{color:green}.archive-report-error{color:red}`; var spooky = `:root.spooky{--xt-background:#171526;--xt-link-hover-bg:#1A1829;--xt-border:#707070;--xt-border-field-focus:#98E;--xt-border-highlight:rgba(145,182,214,.8);--xt-header-dialog-bg:rgba(23,21,38,0.98);--xt-header-dialog-fg:#C49756;--xt-header-link:#FE9600;--xt-quote-color:#634C2C;--xt-dead-link:#FE9600;--xt-qr-link-border:rgb(8,6,23) rgb(8,6,23) rgb(0,0,8);--xt-qr-bg:linear-gradient(#262435,#171526) repeat scroll 0% 0% transparent;--xt-entry-focus-bg:rgba(255,255,255,.33);--xt-unread:rgba(23,21,38,0.5);--xt-watcher:#fe9600;--xt-unread-line:rgb(197,200,198);--xt-filter-highlight:rgba(145,182,214,.5);--xt-qphl:rgba(145,182,214,.8);--xt-highlight-side-arrow:rgb(155,185,210);--xt-fxt-fg:#f0f8ffd1}:root.spooky #arc-list span.quote{color:#634C2C}:root.spooky.shortcut-icons .native-settings{background-image:url('//s.4cdn.org/image/favicon-ws.ico')}:root.spooky #qr .field{background-color:rgb(26,27,29);color:rgb(197,200,198);border-color:rgb(40,41,42)}:root.spooky #qr .field:focus,:root.spooky #qr .field.focus{border-color:rgb(254,150,0) !important;background-color:rgb(30,32,36)}:root.spooky #qr select{border-color:rgb(40,41,42);background:unset}:root.spooky #qr-filename{color:rgb(197,200,198)}:root.spooky #download-all-picker .da-buttons button{background-color:rgb(26,27,29);color:rgb(197,200,198);border-color:rgb(40,41,42)}:root.spooky #download-all-picker .da-buttons button:hover:not(:disabled){background-color:rgb(30,32,36);border-color:rgb(254,150,0)}:root.spooky .unread-line{visibility:visible;opacity:1}`; var style = `:root{--ac-bg:initial;--ac-border:initial;--ac-fg:initial;--gal-cols:initial;--gal-thumbs-width:initial;--tn-w:initial;--undo-duration:initial;--xt-background:initial;--xt-border:initial;--xt-border-field-focus:initial;--xt-border-highlight:initial;--xt-catalog-border-style-filter:initial;--xt-catalog-border-style-own:initial;--xt-catalog-border-style-watched:initial;--xt-catalog-border-width:initial;--xt-catalog-border-width-filter:initial;--xt-catalog-border-width-own:initial;--xt-catalog-border-width-watched:initial;--xt-catalog-filter-dead-link:initial;--xt-catalog-filter-glow-intensity:initial;--xt-catalog-filter-highlight:initial;--xt-catalog-filter-highlight-opacity:initial;--xt-catalog-filter-link:initial;--xt-catalog-filter-quote:initial;--xt-catalog-filter-subject:initial;--xt-catalog-filter-text:initial;--xt-catalog-own-dead-link:initial;--xt-catalog-own-glow-intensity:initial;--xt-catalog-own-highlight:initial;--xt-catalog-own-highlight-opacity:initial;--xt-catalog-own-link:initial;--xt-catalog-own-quote:initial;--xt-catalog-own-subject:initial;--xt-catalog-own-text:initial;--xt-catalog-watched-dead-link:initial;--xt-catalog-watched-glow-intensity:initial;--xt-catalog-watched-highlight:initial;--xt-catalog-watched-highlight-opacity:initial;--xt-catalog-watched-link:initial;--xt-catalog-watched-quote:initial;--xt-catalog-watched-subject:initial;--xt-catalog-watched-text:initial;--xt-dead-link:initial;--xt-dead-link-text-color:initial;--xt-dialog-bg:initial;--xt-dogiri-accent:initial;--xt-dogiri-accent-alt:initial;--xt-dogiri-playhead:initial;--xt-edge-style-ghost:initial;--xt-edge-style-own:initial;--xt-edge-style-you:initial;--xt-edge-width-ghost:initial;--xt-edge-width-own:initial;--xt-edge-width-you:initial;--xt-entry-focus-bg:initial;--xt-entry-size:initial;--xt-filter-highlight:initial;--xt-fxt-fg:initial;--xt-header-dialog-bg:initial;--xt-header-dialog-fg:initial;--xt-header-link:initial;--xt-highlight-edge-width:initial;--xt-highlight-ghost:initial;--xt-highlight-ghost-dead-link:initial;--xt-highlight-ghost-link:initial;--xt-highlight-ghost-opacity:initial;--xt-highlight-ghost-quote:initial;--xt-highlight-ghost-text:initial;--xt-highlight-own:initial;--xt-highlight-own-dead-link:initial;--xt-highlight-own-link:initial;--xt-highlight-own-opacity:initial;--xt-highlight-own-quote:initial;--xt-highlight-own-text:initial;--xt-highlight-shadow:initial;--xt-highlight-side-arrow:initial;--xt-highlight-you:initial;--xt-highlight-you-dead-link:initial;--xt-highlight-you-link:initial;--xt-highlight-you-opacity:initial;--xt-highlight-you-quote:initial;--xt-highlight-you-text:initial;--xt-inline:initial;--xt-link-hover-bg:initial;--xt-link-text-color:initial;--xt-menu-fg:initial;--xt-notification-link:initial;--xt-notification-size:initial;--xt-post-background:initial;--xt-qphl:initial;--xt-qr-bg:initial;--xt-qr-com-preview-quote-color:initial;--xt-qr-link-border:initial;--xt-qr-preview-accent:initial;--xt-qr-preview-bg:initial;--xt-quote-color:initial;--xt-quote-text-color:initial;--xt-scroll-marker-ghost:initial;--xt-scroll-marker-ghost-opacity:initial;--xt-scroll-marker-gutter:initial;--xt-scroll-marker-own:initial;--xt-scroll-marker-own-opacity:initial;--xt-scroll-marker-track:initial;--xt-scroll-marker-unread:initial;--xt-scroll-marker-unread-opacity:initial;--xt-scroll-marker-you:initial;--xt-scroll-marker-you-opacity:initial;--xt-scroll-thumb-bg:initial;--xt-scrollbar-offset:initial;--xt-scrollbar-width:initial;--xt-search-match-bg:initial;--xt-text-color:initial;--xt-undo-ring-color:initial;--xt-unread:initial;--xt-unread-line:initial;--xt-watched-border:initial;--xt-watcher:initial;--xt-watcher-quoting-you:initial}@layer custom-property-declarations{:root{--background:initial;--reply-background-color:initial}}.dialog{border:1px solid;display:block;background-color:inherit}.dialog:not(#qr):not(#thread-watcher):not(#download-all-picker):not(#header-bar):not(#fourchanx-settings){box-shadow:0 1px 2px rgba(0,0,0,.15)}#qr,#thread-watcher,#download-all-picker{box-shadow:-1px 2px 2px rgba(0,0,0,0.25)}.field{background-color:transparent;padding:2px 4px 3px;border:1px solid rgba(128,128,128,.45);border-radius:3px;box-sizing:border-box;color:inherit;font:13px sans-serif;outline:none;margin:0px;transition:color .25s,border-color .25s}.field::placeholder{color:rgba(128,128,128,.85);opacity:1}.field:hover{border-color:rgba(128,128,128,.7)}.field[disabled]{opacity:.55}.move{cursor:move;overflow:hidden}label{cursor:pointer}a[href="javascript:;"]{text-decoration:none}.warning{color:red}:root.sw-yotsuba #boardNavDesktop,:root.sw-yotsuba #boardNavMobile{display:none !important}:root.hide-bottom-board-list __xt-site-boardListBottom{display:none}body.hasDropDownNav{margin-top:5px}.painted{border-radius:3px;padding:0px 2px}[hidden]{display:none !important}:root.sw-yotsuba .opContainer,:root.sw-yotsuba .op{display:block;overflow:visible}:root.sw-yotsuba .reply>.file>.fileText{margin:0 20px}:root.sw-yotsuba #arc-list span.quote{color:#789922}:root.sw-yotsuba .fileText a{unicode-bidi:isolate}:root.sw-yotsuba #g-recaptcha{min-height:78px;height:auto}:root.sw-yotsuba:not(.js-enabled) #postForm{display:table}:root.sw-yotsuba #captchaContainerAlt td:nth-child(2){display:table-cell !important}:root.sw-yotsuba canvas#tegaki-canvas{background:none}:root.sw-yotsuba .post>.file{word-break:break-word}:root.sw-yotsuba:not(.ua-webkit):not(.ua-blink) .fileText{word-wrap:break-word;max-width:calc(100vw - 90px)}:root.sw-yotsuba>body.is_catalog .thread>a>img{display:inline-block}:root.sw-yotsuba .nwsb{display:inline}:root.sw-yotsuba .fileText{max-width:none;white-space:normal}:root.sw-yotsuba #blotter{max-width:100%}@media (max-width:480px){:root.sw-yotsuba hr.aboveMidAd,:root.sw-yotsuba hr.abovePostForm{width:auto}}:root.sw-yotsuba .ad-cnt>*,:root.sw-yotsuba .adg-rects>*,:root.sw-yotsuba .bsa-cnt{height:auto !important}:root.sw-yotsuba:not(.ads-loaded) hr.abovePostForm,:root.sw-yotsuba:not(.ads-loaded) .adg-rects>hr,:root.sw-yotsuba #adg-ol + hr,:root.sw-yotsuba .danbo-slot:empty{display:none}:root.sw-yotsuba .adg-rects{margin:0;font-size:0}:root.sw-yotsuba div.center[style]{display:none !important}#menu>.hide-thread-link{width:auto;height:auto;overflow:visible;background-image:none}#menu label.entry{display:block}#xt-settings-overlay label{display:inline}.intro a[href="javascript:;"],#menu a{margin:0}.gal-buttons.gal-buttons a{font-size:inherit}:root.sw-tinyboard.fixed.top-header:not(.autohide) .boardlist,:root.sw-tinyboard.fixed.top-header:not(.autohide) .bar.top{position:static}:root.sw-tinyboard.fixed.top-header:not(.autohide) div.pages.top{top:auto;bottom:0}:root.sw-tinyboard.fixed.top-header.autohide .boardlist,:root.sw-tinyboard.fixed.top-header.autohide .bar.top{z-index:3}:root[data-host="fufufu.moe"].fixed.top-header:not(.autohide) div.pages.top{top:26px;bottom:auto}:root[data-host="merorin.com"].fixed.top-header:not(.autohide) span.settings{top:26px}:root[data-host="fufufu.moe"]:not(.fixed) #header-bar{margin-top:38px}:root[data-host="lainchan.org"]:not(.fixed) #header-bar{margin-top:17px}:root[data-host="smuglo.li"]:not(.fixed) #header-bar{margin-top:8px}audio.controls-added{display:block;margin:auto;white-space:normal}:root.anti-autoplay div.embed{position:static;width:auto;height:auto;text-align:center}:root.anti-autoplay .autoplay-removed{visibility:visible !important;min-width:640px;min-height:360px}#overlay,#xt-settings-overlay,#qp,#ihover,#tw-ihover,#navlinks,.fixed #header-bar,:root.float #updater,:root.float #thread-stats,#qr,#export-dialog{position:fixed}#overlay,#xt-settings-overlay{z-index:999}#qp,#ihover,#tw-ihover{z-index:60}#menu,.gal-buttons{z-index:50}#updater,#thread-stats{z-index:40}:root.fixed #header-bar,#notifications{z-index:35}#a-gallery{z-index:30}#navlinks{z-index:25}#qr{z-index:20}#embedding{z-index:11}#export-dialog{z-index:1001}:root.fixed-watcher #thread-watcher{z-index:10}:root.fixed:not(.gallery-open) #header-bar:not(:hover){z-index:8}#thread-watcher{z-index:5}.fixed.top-header body{padding-top:2em}.fixed.bottom-header body{padding-bottom:2em}.fixed #header-bar{right:0;left:0;padding:4px;font-size:13px}.fixed.top-header #header-bar{top:0}.fixed.bottom-header #header-bar{bottom:0}#header-bar{border-width:0;transition:all .1s .05s ease-in-out}:root.fixed #header-bar{box-shadow:-5px 1px 10px rgba(0,0,0,0.20)}:root.centered-links #header-bar{display:grid;grid-template-columns:1fr auto 1fr;align-items:center}:root.centered-links #board-list{grid-column:2;justify-self:center}:root.centered-links #shortcuts{grid-column:3;justify-self:end;justify-content:end;text-align:right}.fixed.top-header #header-bar{border-bottom-width:1px}.fixed.bottom-header #header-bar{box-shadow:0 -1px 2px rgba(0,0,0,.15);border-top-width:1px}.fixed.bottom-header #header-bar .menu-button .icon{transform:rotate(180deg) translateY(-.125em)}.fixed #header-bar.autohide:not(:hover){box-shadow:none;transition:all .8s .6s cubic-bezier(.55,.055,.675,.19)}.fixed.top-header #header-bar.autohide:not(:hover){margin-bottom:-1em;transform:translateY(-100%)}.fixed.bottom-header #header-bar.autohide:not(:hover){transform:translateY(100%)}#scroll-marker{left:0;right:0;height:10px;position:absolute}#header-bar:not(.autohide) #scroll-marker{pointer-events:none}#header-bar #scroll-marker{display:none}.fixed #header-bar #scroll-marker{display:block}.fixed.top-header #header-bar #scroll-marker{top:100%}.fixed.bottom-header #header-bar #scroll-marker{bottom:100%}#board-list a{text-decoration:none;padding:1px}:root.hide-board-banner #bannerCnt{display:none}#shortcuts:empty{display:none}.dead-thread,.disabled:not(.replies-quoting-you){opacity:.45}#shortcuts{float:right;display:flex;gap:4px}#shortcuts .icon{font-size:14px}:root.autohiding-scrollbar #shortcuts{margin-right:12px}:root.shortcut-icons .native-settings{font-size:0;color:transparent;display:inline-block;vertical-align:top;height:14px;width:14px;background:url('//s.4cdn.org/image/favicon.ico') -1px -1px no-repeat}#navbotright,#navtopright{display:none}#toggleMsgBtn{display:none !important}.current,:root.sw-yotsuba div#boardNavDesktopFoot a.current{font-weight:bold}.brackets-wrap::before{content:"["}.brackets-wrap::after{content:"]"}#notifications{position:fixed;top:0;height:0;text-align:center;right:0;left:0;visibility:visible}#notifications:empty{display:none}:root.fixed.top-header:not(.gallery-open) #header-bar #notifications,:root.fixed.top-header #header-bar.autohide #notifications{position:absolute;top:100%}.notification{box-sizing:border-box;color:#FFF;font-weight:700;text-shadow:0 1px 2px rgba(0,0,0,.5);box-shadow:0 1px 2px rgba(0,0,0,.15);border-radius:2px;margin:1px auto;width:550px;max-width:100%;position:relative;transition:all .25s ease-in-out;padding:6px 5px 6px 20px;display:flex;gap:5px;align-items:center}.notification.error{background-color:hsla(0,100%,38%,.9)}.notification.warning{background-color:hsla(36,100%,38%,.9)}.notification.info{background-color:hsla(200,100%,38%,.9)}.notification.success{background-color:hsla(104,100%,38%,.9)}.notification a{color:var(--xt-notification-link,currentColor) !important}.notification>.close{order:1}.message{overflow-wrap:break-word;box-sizing:border-box;padding:0 5px;max-height:200px;width:100%;overflow:auto;white-space:pre-line}.message a{text-decoration:underline}:root.tainted .report-error{display:none}:root.fourchan-x body{box-sizing:border-box}html.xt-settings-open{overflow:hidden}#overlay,#xt-settings-overlay{background-color:rgba(0,0,0,.5);display:flex;top:0;left:0;height:100%;width:100%}#fourchanx-settings{box-sizing:border-box;border:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:6px;box-shadow:0 12px 48px rgba(0,0,0,.5);height:600px;max-height:100%;min-height:480px;width:1000px;max-width:100%;min-width:min(360px,100%);margin:auto;padding:0;display:flex;flex-direction:column;overflow:hidden;resize:both;position:absolute}.settings-titlebar{box-sizing:border-box;border-bottom:1px solid rgba(128,128,128,.25);display:flex;align-items:center;justify-content:space-between;padding:6px 8px;cursor:move}.settings-titlebar>.settings-search{flex:0 0 auto;margin:0;position:relative;width:15ch}.settings-titlebar>.settings-search input{box-sizing:border-box;width:100%;transition:width .28s cubic-bezier(.22,.61,.36,1),background-color .28s ease,box-shadow .28s ease}#xt-settings-overlay #fourchanx-settings .settings-titlebar>.settings-search input:focus{position:absolute;top:50%;left:0;transform:translateY(-50%);width:30ch;max-width:70vw;z-index:5;background-color:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));box-shadow:0 1px 6px rgba(0,0,0,.35)}.settings-titlebar-actions{display:flex;align-items:center;gap:10px;font-size:0.85em}.settings-titlebar-actions>a.settings-nav-toggle{display:none}.settings-titlebar-actions>a:not(.close){text-decoration:none;opacity:0.8}.settings-titlebar-actions>a:not(.close):hover{opacity:1;text-decoration:underline}.settings-titlebar-actions>a.accordion-toggle,.settings-titlebar-actions>a.settings-nav-toggle,.settings-titlebar-actions>a.expand-all,.settings-titlebar-actions>a.collapse-all{display:inline-flex;align-items:center;padding:2px 3px;border-radius:3px;line-height:1}.settings-titlebar-actions>a.accordion-toggle:hover,.settings-titlebar-actions>a.settings-nav-toggle:hover,.settings-titlebar-actions>a.expand-all:hover,.settings-titlebar-actions>a.collapse-all:hover{text-decoration:none;background:rgba(128,128,128,.2)}.settings-titlebar-actions>a.accordion-toggle .icon{font-size:1.1em}.settings-nav-scroll{display:none}.settings-titlebar-actions>a.accordion-toggle.accordion-active{opacity:1;color:var(--xt-link-text-color,#34b);background:rgba(128,128,128,.22)}.settings-titlebar-actions.accordion-on>a.expand-all{opacity:.35;pointer-events:none}.remember-layout-toggle{align-items:center;cursor:pointer;display:inline-flex;gap:4px;opacity:.85;user-select:none}.remember-layout-toggle:hover{opacity:1}.remember-layout-toggle>span{white-space:nowrap}.remember-layout-toggle input{margin:0}.highlight-next-toggle{align-items:center;cursor:pointer;display:inline-flex;gap:4px;opacity:.85;user-select:none}.highlight-next-toggle:hover{opacity:1}.highlight-next-toggle>span{white-space:nowrap}.highlight-next-toggle input{margin:0}.highlight-next-settings :is( [data-next-status] .setting-title,.next-summary-title[data-next-status],.styling-section-summary-text[data-next-status],label[data-next-status],[data-next-status]>label>span:first-child )::after{display:inline-block;margin-left:6px;padding:0 5px;border-radius:8px;font-size:9px;font-weight:700;letter-spacing:.04em;line-height:14px;text-transform:uppercase;vertical-align:middle;white-space:nowrap}.highlight-next-settings :is( [data-next-status="added"] .setting-title,.next-summary-title[data-next-status="added"],.styling-section-summary-text[data-next-status="added"],label[data-next-status="added"],[data-next-status="added"]>label>span:first-child )::after{content:'neXT';color:#fff;background:#3fb950;text-transform:none}.highlight-next-settings :is( [data-next-status="changed"] .setting-title,.next-summary-title[data-next-status="changed"],.styling-section-summary-text[data-next-status="changed"],label[data-next-status="changed"],[data-next-status="changed"]>label>span:first-child )::after{content:'changed';color:#1a1a1a;background:#d29922}html.highlight-next-global #menu .entry[data-next-status]:not(.has-submenu):not(.watcher-thumbnail-controls)::after,html.highlight-next-global #menu .entry.has-submenu[data-next-status]>.menu-indicator::before,html.highlight-next-global #menu .entry.watcher-thumbnail-controls[data-next-status] .watcher-thumb-row::after{content:'';display:inline-block;width:7px;height:7px;border-radius:50%;vertical-align:middle;flex:none;background:var(--next-dot,#3fb950)}html.highlight-next-global #menu .entry[data-next-status]:not(.has-submenu):not(.watcher-thumbnail-controls)::after{margin-left:6px}html.highlight-next-global #menu .entry.has-submenu[data-next-status]>.menu-indicator::before{margin-right:6px}html.highlight-next-global #menu .entry.watcher-thumbnail-controls[data-next-status] .watcher-thumb-row{grid-template-columns:100px max-content auto}html.highlight-next-global #menu .entry.watcher-thumbnail-controls[data-next-status] .watcher-thumb-slider{grid-template-columns:84px 44px 18px}html.highlight-next-global #menu .entry.watcher-thumbnail-controls[data-next-status] .watcher-thumb-row::after{justify-self:end}html.highlight-next-global #menu .entry[data-next-status="added"]{--next-dot:#3fb950}html.highlight-next-global #menu .entry[data-next-status="changed"]{--next-dot:#d29922}#fourchanx-settings>.settings-body{flex:1;min-height:0;display:flex}#fourchanx-settings>.settings-body>nav{box-sizing:border-box;border-right:1px solid rgba(128,128,128,.25);padding:8px;display:flex;flex-direction:column;flex:0 0 max-content;min-width:0;max-width:180px;width:max-content}#fourchanx-settings>.settings-body>nav a{text-decoration:none}#fourchanx-settings>.settings-body>nav .settings-search{margin:0 0 6px}#fourchanx-settings>.settings-body>nav .settings-search input{box-sizing:border-box;max-width:100%;width:15ch}#xt-settings-overlay .field:not(.styling-color-hex):not(.styling-saved-palette-name):not(.styling-hl-widthnum):not(.custom-css-textarea){background-color:transparent;border-color:rgba(128,128,128,.45);border-radius:3px;color:inherit}#xt-settings-overlay select.field option{background-color:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));color:inherit}#xt-settings-overlay .close{text-decoration:none;padding:2px 4px;margin:0}.section-container{flex:1;position:relative;overflow:auto;padding:8px 10px;overscroll-behavior:contain}:root:not(.xt-external-style) .section-container::-webkit-scrollbar{width:6px}:root:not(.xt-external-style) .section-container::-webkit-scrollbar-button{display:none;width:0;height:0}:root:not(.xt-external-style) .section-container::-webkit-scrollbar-track{background:transparent}:root:not(.xt-external-style) .section-container::-webkit-scrollbar-thumb{background-color:rgba(128,128,128,.5);border-radius:3px}:root:not(.xt-external-style) .section-container::-webkit-scrollbar-thumb:hover{background-color:rgba(128,128,128,.8)}@supports not selector(::-webkit-scrollbar){:root:not(.xt-external-style) .section-container{scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.5) transparent}}:root:not(.xt-external-style) #xt-settings-overlay textarea::-webkit-scrollbar,:root:not(.xt-external-style) .sections-list::-webkit-scrollbar{width:6px;height:6px}:root:not(.xt-external-style) #xt-settings-overlay textarea::-webkit-scrollbar-button,:root:not(.xt-external-style) .sections-list::-webkit-scrollbar-button{display:none;width:0;height:0}:root:not(.xt-external-style) #xt-settings-overlay textarea::-webkit-scrollbar-track,:root:not(.xt-external-style) .sections-list::-webkit-scrollbar-track{background:transparent}:root:not(.xt-external-style) #xt-settings-overlay textarea::-webkit-scrollbar-thumb,:root:not(.xt-external-style) .sections-list::-webkit-scrollbar-thumb{background-color:rgba(128,128,128,.5);border-radius:3px}:root:not(.xt-external-style) #xt-settings-overlay textarea::-webkit-scrollbar-thumb:hover,:root:not(.xt-external-style) .sections-list::-webkit-scrollbar-thumb:hover{background-color:rgba(128,128,128,.8)}@supports not selector(::-webkit-scrollbar){:root:not(.xt-external-style) #xt-settings-overlay textarea,:root:not(.xt-external-style) .sections-list{scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.5) transparent}}:root:not(.xt-external-style) #xt-settings-overlay .custom-css-textarea::-webkit-scrollbar{width:4px;height:4px}:root:not(.xt-external-style) #xt-settings-overlay .custom-css-textarea::-webkit-scrollbar-thumb{background-color:var(--custom-css-border,var(--xt-border,rgba(128,128,128,.5)))}@supports not selector(::-webkit-scrollbar){:root:not(.xt-external-style) #xt-settings-overlay .custom-css-textarea{scrollbar-color:var(--custom-css-border,var(--xt-border,rgba(128,128,128,.5))) transparent}}.css-autocomplete{position:fixed;z-index:2147483646;min-width:140px;max-width:320px;max-height:220px;overflow-y:auto;margin:0;padding:3px;border-radius:5px;background:var(--ac-bg,#fff);color:var(--ac-fg,#222);border:1px solid var(--ac-border,rgba(128,128,128,.4));box-shadow:0 4px 14px rgba(0,0,0,.28);font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}.css-autocomplete::-webkit-scrollbar{width:6px}.css-autocomplete::-webkit-scrollbar-thumb{background-color:var(--ac-border,rgba(128,128,128,.5));border-radius:3px}.css-ac-item{padding:2px 7px;border-radius:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.css-ac-item.active,.css-ac-item:hover{background:color-mix(in srgb,var(--ac-fg,#222) 16%,transparent)}.section-container>section{margin-bottom:14px}.settings-section-block{margin:0 0 12px}.settings-section-content>:last-child{margin-bottom:0}.settings-section-header{border-bottom:1px solid rgba(128,128,128,.25);font-size:18px;font-weight:700;line-height:1.25;margin:0 0 10px;padding:0 0 6px}#fourchanx-settings.settings-searching .settings-section-header{display:block}.settings-search-hidden{display:none !important}::highlight(fourchanx-settings-search),::highlight(fourchanx-index-search){background:rgba(255,214,83,.5);color:inherit}.section-container mark{background:rgba(255,214,83,.5);color:inherit;padding:0}.settings-search-keyword-match{box-shadow:inset 3px 0 0 rgba(255,214,83,.7)}.search-hit{outline:2px solid rgba(255,214,83,.7);outline-offset:1px;box-shadow:0 0 6px 2px rgba(255,214,83,.55);border-radius:2px}.index-search-matched{background:var(--xt-search-match-bg,transparent)}.sections-list{flex:1;display:flex;flex-direction:column;gap:2px;overflow:auto}.sections-list>a{white-space:nowrap;border-radius:3px;padding:3px 5px}.sections-list>a:hover,.sections-list>a:focus{background:rgba(128,128,128,.12)}.export,.import,.reset{cursor:pointer;text-decoration:none !important}.tab-selected{background:rgba(128,128,128,.18);font-weight:700}#fourchanx-settings.settings-nav-horizontal .settings-titlebar{flex-wrap:nowrap;gap:4px 10px}#fourchanx-settings.settings-nav-horizontal .settings-titlebar-actions{flex:0 0 auto;white-space:nowrap}#fourchanx-settings.settings-nav-horizontal .settings-titlebar>.sections-list{flex:1 1 auto;min-width:0;flex-direction:row;flex-wrap:nowrap;align-items:center;gap:0;overflow-x:auto;overflow-y:hidden;position:relative;cursor:grab;scrollbar-width:none;touch-action:pan-x}#fourchanx-settings.settings-nav-horizontal .settings-titlebar>.sections-list::-webkit-scrollbar{display:none;width:0;height:0}#fourchanx-settings.settings-nav-horizontal .settings-titlebar>.sections-list.sections-list-dragging{cursor:grabbing}#fourchanx-settings.settings-nav-horizontal .settings-titlebar>.sections-list.sections-list-dragging>a{pointer-events:none}#fourchanx-settings.settings-nav-horizontal .settings-titlebar>.sections-list>a{flex:0 0 auto;padding:3px 7px;border-radius:0}#fourchanx-settings.settings-nav-horizontal .settings-titlebar>.sections-list>a:hover,#fourchanx-settings.settings-nav-horizontal .settings-titlebar>.sections-list>a:focus{background:none;text-decoration:underline}#fourchanx-settings.settings-nav-horizontal .settings-titlebar>.sections-list>a.tab-selected{background:none}#fourchanx-settings.settings-nav-horizontal .settings-titlebar>.sections-list>a + a{border-left:1px solid rgba(128,128,128,.4)}#fourchanx-settings.settings-nav-horizontal.settings-nav-overflow-left>.settings-titlebar>.settings-nav-scroll-left,#fourchanx-settings.settings-nav-horizontal.settings-nav-overflow-right>.settings-titlebar>.settings-nav-scroll-right{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;border:0;border-radius:3px;color:inherit;cursor:pointer;line-height:1}#fourchanx-settings.settings-nav-horizontal.settings-nav-overflow-left>.settings-titlebar>.settings-nav-scroll-left{margin:0 -10px 0 -6px;padding:2px 12px 2px 3px;background:linear-gradient(to left,transparent,var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas))) 45%)}#fourchanx-settings.settings-nav-horizontal.settings-nav-overflow-right>.settings-titlebar>.settings-nav-scroll-right{margin:0 -6px 0 -10px;padding:2px 3px 2px 12px;background:linear-gradient(to right,transparent,var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas))) 45%)}#fourchanx-settings.settings-nav-horizontal.settings-nav-overflow-left>.settings-titlebar>.settings-nav-scroll-left:hover,#fourchanx-settings.settings-nav-horizontal.settings-nav-overflow-left>.settings-titlebar>.settings-nav-scroll-left:focus{background:linear-gradient(to left,transparent,var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas))) 35%),rgba(128,128,128,.16)}#fourchanx-settings.settings-nav-horizontal.settings-nav-overflow-right>.settings-titlebar>.settings-nav-scroll-right:hover,#fourchanx-settings.settings-nav-horizontal.settings-nav-overflow-right>.settings-titlebar>.settings-nav-scroll-right:focus{background:linear-gradient(to right,transparent,var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas))) 35%),rgba(128,128,128,.16)}#fourchanx-settings.settings-nav-horizontal>.settings-body>nav{display:none}#fourchanx-settings:not(.settings-nav-collapsed) .settings-titlebar-actions>a.settings-nav-toggle{display:none}#fourchanx-settings.settings-nav-collapsed .settings-titlebar{gap:6px}#fourchanx-settings.settings-nav-collapsed .settings-titlebar-actions{flex:1;gap:6px;min-width:0}#fourchanx-settings.settings-nav-collapsed .settings-titlebar-actions>a.settings-nav-toggle{display:inline-flex}#fourchanx-settings.settings-nav-collapsed .settings-titlebar-actions>.settings-search{flex:1 1 auto;margin:0;min-width:0}#fourchanx-settings.settings-nav-collapsed .settings-titlebar-actions>.settings-search input{box-sizing:border-box;width:100%}#fourchanx-settings.settings-nav-collapsed>.settings-body{position:relative}#fourchanx-settings.settings-nav-collapsed>.settings-body>nav{display:none;position:absolute;top:0;left:0;z-index:4;box-sizing:border-box;width:min(260px,calc(100% - 16px));max-height:calc(100% - 16px);margin:8px;padding:8px;border:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:5px;background:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));box-shadow:0 8px 28px rgba(0,0,0,.4);overflow:auto}#fourchanx-settings.settings-nav-collapsed.settings-nav-open>.settings-body>nav{display:flex}#fourchanx-settings.settings-nav-collapsed>.settings-body>nav .sections-list{max-height:calc(100dvh - 150px)}.settings-footer{border-top:1px solid rgba(128,128,128,.25);font-size:11px;line-height:1.6;padding:6px 8px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 12px;overflow:visible}.settings-footer-menu{display:flex;align-items:center;position:relative}.settings-footer-menu-toggle{display:none;align-items:center;justify-content:center;width:24px;height:24px;padding:3px;border:0;border-radius:3px;background:transparent;color:inherit;cursor:pointer;line-height:1}.settings-footer-menu-toggle:hover,.settings-footer-menu-toggle:focus{background:rgba(128,128,128,.18)}.settings-actions{display:flex;gap:10px;white-space:nowrap}.credits{display:flex;gap:10px;white-space:nowrap;margin-left:auto}.imp-exp-result{font-size:11px;margin:0}#fourchanx-settings.settings-compact-content .settings-footer{position:relative;gap:4px 8px}#fourchanx-settings.settings-compact-content .settings-footer-menu{order:0;z-index:8;margin-left:0}#fourchanx-settings.settings-compact-content .settings-footer-menu-toggle{display:inline-flex}#fourchanx-settings.settings-compact-content .settings-footer-menu>.settings-actions{display:none;position:absolute;left:0;right:auto;bottom:100%;z-index:8;box-sizing:border-box;min-width:190px;max-width:calc(100vw - 32px);max-height:min(260px,calc(100dvh - 120px));margin-bottom:6px;padding:6px;flex-direction:column;align-items:stretch;gap:2px;border:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:5px;background:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));box-shadow:0 8px 24px rgba(0,0,0,.35);overflow:auto;white-space:normal}#fourchanx-settings.settings-compact-content .settings-footer-menu.settings-footer-menu-open>.settings-actions{display:flex}#fourchanx-settings.settings-compact-content .settings-actions>a,#fourchanx-settings.settings-compact-content .settings-actions>label{box-sizing:border-box;display:flex;align-items:center;gap:6px;width:100%;padding:4px 6px;border-radius:3px;white-space:nowrap}#fourchanx-settings.settings-compact-content .settings-actions>a:hover,#fourchanx-settings.settings-compact-content .settings-actions>a:focus,#fourchanx-settings.settings-compact-content .settings-actions>label:hover{background:rgba(128,128,128,.14)}#fourchanx-settings.settings-compact-content .credits{order:1;flex:1 1 auto;justify-content:flex-end;min-width:0;margin-left:auto;gap:8px;overflow:hidden}#fourchanx-settings.settings-compact-content .credits a{overflow:hidden;text-overflow:ellipsis}#fourchanx-settings.settings-compact-content .imp-exp-result{order:2;flex:1 0 100%}.imp-exp-picker-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:1002}.imp-exp-picker::-webkit-scrollbar{width:6px;height:6px}.imp-exp-picker::-webkit-scrollbar-button{display:none;width:0;height:0}.imp-exp-picker::-webkit-scrollbar-track{background:transparent}.imp-exp-picker::-webkit-scrollbar-thumb{background-color:var(--xt-border,rgba(128,128,128,.5));border-radius:3px}.imp-exp-picker::-webkit-scrollbar-thumb:hover{background-color:var(--xt-border,rgba(128,128,128,.8))}@supports not selector(::-webkit-scrollbar){.imp-exp-picker{scrollbar-width:thin;scrollbar-color:var(--xt-border,rgba(128,128,128,.5)) transparent}}.imp-exp-picker{min-width:260px;max-width:80vw;max-height:80vh;overflow:auto;padding:1em 1.25em;display:flex;flex-direction:column;gap:.5em}.imp-exp-picker-title{margin:0 0 .25em;font-size:1.1em}.imp-exp-picker-list{display:flex;flex-direction:column;gap:.35em}.imp-exp-picker-section{display:flex;flex-direction:column;gap:.15em}.imp-exp-picker-row label{display:flex;align-items:center;gap:.35em;cursor:pointer}.imp-exp-picker-section-title{font-weight:bold}.imp-exp-picker-children{display:flex;flex-direction:column;gap:.15em;margin-left:1.3em}.imp-exp-picker-toggle{border-bottom:1px solid currentColor;padding-bottom:.25em;margin-bottom:.15em;opacity:.85;font-weight:bold}.imp-exp-picker-empty{margin:.5em 0;opacity:.8}.imp-exp-picker-buttons{display:flex;justify-content:flex-end;gap:.5em;margin-top:.5em}.section-sauce ul,.section-advanced ul{list-style:none;margin:0}.section-sauce ul{padding:8px}.section-advanced ul{padding:0px}.section-sauce li,.section-advanced li{padding-left:4px}.section-main ul{margin:0;padding:0 0 0 16px}.section-main li{white-space:pre-line;list-style:disc}.section-main li:not(:first-of-type){margin-top:4px}.section-main label{text-decoration:underline}div[data-checked="false"]>.suboption-list{display:none}.suboption-list{position:relative}.suboption-list>div{position:relative;padding-left:1.4em}.suboption-list>div::before{content:"";display:inline-block;position:absolute;left:.7em;width:.7em;height:.6em;border-left:1px solid;border-bottom:1px solid}.suboption-list>div:not(:last-of-type)::after{content:"";display:inline-block;position:absolute;left:.7em;top:.6em;bottom:0;width:0;border-left:1px solid}#xt-settings-overlay .description{display:inline;opacity:.72}#fourchanx-settings.settings-description-tooltips:not(.settings-searching) .description{display:none}#fourchanx-settings.settings-touch-descriptions.settings-description-tooltips:not(.settings-searching) [data-setting-description] label,#fourchanx-settings.settings-touch-descriptions.settings-description-tooltips:not(.settings-searching) [data-setting-description] .setting-title{cursor:pointer}.settings-mobile-description{display:none}#fourchanx-settings.settings-touch-descriptions.settings-description-tooltips:not(.settings-searching) .settings-mobile-description{display:block;box-sizing:border-box;margin:4px 0 4px 1.6em;max-width:calc(100% - 1.6em);padding:5px 7px;border-left:2px solid rgba(128,128,128,.55);background:rgba(128,128,128,.09);border-radius:3px;line-height:1.35;opacity:.86}#fourchanx-settings.settings-searching .description{display:inline}#xt-settings-overlay .section-main p{margin:.5em 0 0}.section-filter ul,.section-filters ul{padding:0}.section-filter li,.section-filters li{margin:10px 40px;list-style:disc}.section-filter textarea,.section-filters textarea{height:500px}.settings-guide{margin-top:10px}.settings-guide>summary{font-weight:600}.section-main a,.section-filter a,.section-filters a,.section-advanced a{text-decoration:underline}#xt-settings-overlay .thread-watcher-inline-number{margin-left:8px;display:inline-flex;align-items:center;gap:4px}#xt-settings-overlay .thread-watcher-inline-subsetting{display:block;margin:4px 0 0 22px}#xt-settings-overlay .thread-watcher-size-input{width:3.2em}#xt-settings-overlay .thread-watcher-preview-size-input{width:3.2em}#xt-settings-overlay .thread-watcher-height-input{width:4.2em}#xt-settings-overlay .thread-watcher-width-input{width:4.2em}#xt-settings-overlay .thread-watcher-attach-loc{width:5.5em;font-size:90%}.watcher-sort-check{display:inline-block;width:1em;text-align:center;margin-right:4px}.entry.watcher-sort-then-by{margin-top:3px;padding-top:5px;border-top:1px solid color-mix(in srgb,currentColor 25%,transparent)}.entry.watcher-sort-then-by.disabled{pointer-events:none}.entry.watcher-attach-location-option.disabled{pointer-events:none;opacity:.45;cursor:default}.entry.watcher-attach-enabled-option{margin-bottom:3px;padding-bottom:5px;border-bottom:1px solid color-mix(in srgb,currentColor 25%,transparent)}.section-filters .settings-subnav{display:flex;flex-wrap:wrap;gap:4px;margin:0 0 8px}.section-filters .settings-subnav-tab{background:transparent;border:1px solid;border-radius:3px;color:inherit;cursor:pointer;font:inherit;line-height:1.2;padding:2px 6px;text-decoration:none}.section-filters .settings-subnav-tab-selected{font-weight:700;background:rgba(128,128,128,.14)}.section-filters .filter-stats{border-top:1px solid;font-size:11px;line-height:1.45;margin-top:8px;padding-top:8px}.section-filters .filter-preview-group{margin:0 0 12px}.section-filters .filter-preview-group:last-child{margin-bottom:0}.section-filters .filter-preview-heading{font-weight:700;margin:0 0 4px}.section-filters .filter-stats-summary{font-weight:700;margin:0 0 6px}.section-filters .filter-stats-empty{opacity:.8}.section-filters .filter-stat-row{margin:0 0 6px}.section-filters .filter-stat{margin:0}.section-filters .filter-stat>summary{cursor:pointer;list-style-position:inside}.section-filters .filter-stat-threads{margin:4px 0 0 18px;padding:0}.section-filters .filter-stat-threads>li{list-style:disc;margin:2px 0}.section-filters .filter-stat-invalid{color:#c33}.section-filters .filter-stat-count{font-weight:700}.section-filters .filter-stat-more{opacity:.8}.section-filters .easy-filters-list{gap:8px}.section-filters .easy-filter-tile{box-sizing:border-box;border:1px solid rgba(128,128,128,.3);border-radius:5px;padding:6px 8px;display:flex;gap:6px}.section-filters .easy-filter-on{display:inline-flex;align-items:center;cursor:pointer}.section-filters .easy-filter-off .easy-filter-field,.section-filters .easy-filter-off .easy-filter-remove{opacity:.4}.section-filters .easy-filter-field{display:flex;align-items:center;gap:5px}.section-filters .easy-filter-field>span{flex:0 0 auto;opacity:.85}.section-filters .easy-filter-field>span[data-abbr]::after{content:attr(data-abbr)}.section-filters .layout-grid .easy-filter-field>span[data-abbr]::after{content:attr(data-full)}.section-filters .easy-filters-list.layout-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr))}.section-filters .layout-grid .easy-filter-tile{flex-direction:column}.section-filters .layout-grid .easy-filter-tile-head{display:flex;align-items:center;justify-content:space-between}.section-filters .layout-grid .easy-filter-field{justify-content:space-between}.section-filters .layout-grid .easy-filter-field .field{flex:1 1 auto;min-width:0}.section-filters .easy-filters-list.layout-list{display:flex;flex-direction:column}.section-filters .layout-list .easy-filter-tile{flex-direction:row;flex-wrap:wrap;align-items:center;gap:10px}.section-filters .layout-list .easy-filter-tile-head{display:contents}.section-filters .layout-list .easy-filter-on{align-self:stretch;padding-right:10px;border-right:1px solid rgba(128,128,128,.3)}.section-filters .layout-list .easy-filter-remove{order:1;margin-left:auto}.section-filters .layout-list .easy-filter-field{width:auto}.section-filters .layout-list .easy-filter-pattern{width:160px}.section-filters .layout-list .easy-filter-boards{width:112px}.section-filters .layout-list .easy-filter-type{width:132px}.section-filters .layout-list .easy-filter-class{width:84px}.section-filters .easy-filter-color-cell{align-items:center;display:inline-flex;gap:4px}.section-filters .easy-filter-class{min-width:90px;width:100%}.section-filters .easy-filter-color{width:34px;height:24px;padding:0;border:1px solid rgba(128,128,128,.4);border-radius:3px;background:none;cursor:pointer;vertical-align:middle}.section-filters .easy-filter-color::-webkit-color-swatch-wrapper{padding:2px}.section-filters .easy-filter-color::-webkit-color-swatch,.section-filters .easy-filter-color::-moz-color-swatch{border:none;border-radius:2px}.section-filters .easy-filter-color:disabled{opacity:.4;cursor:default}.section-filters .easy-filter-remove{font-size:13px;height:20px;line-height:16px;min-width:20px;padding:0;text-align:center;width:20px}.section-filters .easy-filters-controls{margin-bottom:8px}.section-filters .easy-filter-layout{margin-left:10px}.section-filters .easy-filter-layout-btn.selected{font-weight:700;text-decoration:underline}.section-filters .easy-filter-status{margin-left:8px;opacity:.85}.section-styling .styling-table{border-collapse:separate;border-spacing:6px;margin:6px 0}.section-styling .styling-table th{text-align:left;font-weight:400;padding-right:8px}.section-styling .styling-table input[type="color"]{vertical-align:middle;height:22px;width:46px;padding:0;border:1px solid rgba(128,128,128,.35);background:transparent}.section-styling .styling-table input[type="range"]{vertical-align:middle}.section-styling .styling-table [data-clear]{margin-left:4px;padding:0 6px;line-height:18px;cursor:pointer}.section-styling .styling-actions{margin-top:6px;display:flex;flex-wrap:wrap;gap:8px}.section-styling .styling-hl-group-palette .styling-hl-acc-item-palette.styling-hl-open .styling-hl-acc-body{display:block;grid-template-columns:none;padding-top:8px}.section-styling .styling-palette-toprow{align-items:center;display:flex;flex-wrap:wrap;gap:8px 12px}.section-styling .styling-palette-toprow .styling-actions{margin-left:auto;margin-top:0}.section-styling .styling-highlight-intro{margin:0 0 4px}.section-styling .styling-highlight-intro>p{margin:0}.section-styling .styling-palette-suggestions{margin-top:8px;border:1px solid color-mix(in srgb,currentColor 22%,transparent);border-radius:4px;padding:6px 8px}.section-styling .styling-palette-header{align-items:center;display:flex;flex-wrap:wrap;gap:6px 10px;margin-bottom:6px}.section-styling .styling-palette-title{font-weight:600}.section-styling .styling-palette-note{opacity:.8}.section-styling .styling-palette-list{display:grid;gap:6px}.section-styling .styling-palette-row{align-items:center;border-radius:3px;display:grid;gap:6px;grid-template-columns:minmax(110px,1fr) auto auto auto;padding:3px 6px}.section-styling .styling-palette-row .styling-palette-name{font-size:13px}.section-styling .styling-palette-row:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.section-styling .styling-palette-name{font-weight:600;min-width:0}.section-styling .styling-saved-palette-row .styling-palette-name{font-size:13px}.section-styling .styling-palette-swatches{align-items:center;display:inline-flex;gap:4px}.section-styling .styling-palette-swatch{border:1px solid color-mix(in srgb,currentColor 24%,transparent);border-radius:2px;display:inline-block;height:14px;width:18px}.section-styling .styling-palette-swatch-marker{border-radius:50%;margin-left:2px;width:14px}.section-styling .styling-saved-palettes{margin-top:8px}.section-styling .styling-saved-palette-controls{align-items:center;display:flex;flex-wrap:wrap;gap:6px}.section-styling .styling-saved-palette-name.field{min-width:180px}.section-styling .styling-saved-palettes-list{display:grid;gap:6px;margin-top:6px}.section-styling .styling-saved-palette-row{align-items:center;border-radius:3px;display:grid;gap:6px;grid-template-columns:minmax(110px,1fr) auto auto auto;padding:3px 6px}.section-styling .styling-saved-palette-row:nth-child(even){background:color-mix(in srgb,currentColor 5%,transparent)}.section-styling .styling-saved-palette-row-theme{background:color-mix(in srgb,currentColor 8%,transparent)}.section-styling .styling-saved-palette-row:hover{background:color-mix(in srgb,currentColor 10%,transparent)}.styling-preview-thread{background:transparent;border:0;margin:0;padding:8px;width:fit-content}.styling-preview-thread>.thread{display:block;margin:0;padding:0;text-align:left}.styling-preview-catalog{background:transparent;border:0;display:flex;flex-wrap:nowrap;gap:8px;margin:0;min-width:0;padding:8px;text-align:center;width:fit-content}.styling-preview-catalog>.catalog-thread{flex:0 0 165px;height:225px;margin:0;width:165px}.styling-preview-catalog .catalog-thumb{height:auto !important;max-height:92px;max-width:118px;width:auto !important}.styling-preview-catalog .catalog-post>.postMessage{max-height:4.5em;overflow:hidden}.styling-preview-post{border:none;border-radius:0;padding:0;margin:0 0 6px;background:transparent}.styling-preview-post::after{content:"";display:block;clear:both}.styling-preview-post:last-child{margin-bottom:0}.styling-preview-post>.reply{display:inline-block;max-width:min(100%,860px);width:auto}.styling-preview-post .sideArrows{display:inline-block;float:left;margin-right:4px}@media (max-width:700px){.styling-preview-catalog{flex-wrap:wrap;min-width:0}.styling-preview-catalog>.catalog-thread{flex-basis:140px;width:140px}}.styling-preview-post .postInfo,.styling-preview-post .postMessage{color:var(--xt-text-color)}.styling-preview-post .subject{color:inherit}.styling-preview-post a{color:var(--xt-link-text-color)}.styling-preview-post .quote,.styling-preview-catalog .quote{color:var(--xt-quote-text-color,var(--xt-quote-color,#789922)) !important}.styling-preview-post .backlink.deadlink,.styling-preview-post .quotelink.deadlink{color:var(--xt-dead-link-text-color,var(--xt-dead-link)) !important}.styling-preview-post.yourPost>.reply,.styling-preview-post.quotesYou>.reply,.styling-preview-post.from-archive>.reply{background-color:var(--xt-post-background,var(--xt-background,transparent)) !important;border-left:var(--xt-highlight-edge-width,3px) solid transparent !important}.styling-preview[data-highlight-own="true"] .styling-preview-post.yourPost>.reply{background-color:color-mix(in srgb,var(--xt-highlight-own,var(--xt-border-highlight)) calc(var(--xt-highlight-own-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,transparent))) !important}.styling-preview[data-highlight-you="true"] .styling-preview-post.quotesYou>.reply{background-color:color-mix(in srgb,var(--xt-highlight-you,var(--xt-border-highlight)) calc(var(--xt-highlight-you-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,transparent))) !important}.styling-preview[data-highlight-ghost="true"] .styling-preview-post.from-archive>.reply{background-color:color-mix(in srgb,var(--xt-highlight-ghost,#888) calc(var(--xt-highlight-ghost-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,transparent))) !important}.styling-preview[data-highlight-own="true"][data-edge-own="true"] .styling-preview-post.yourPost>.reply{border-left:var(--xt-edge-width-own,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-own,dashed) color-mix(in srgb,var(--xt-highlight-own,var(--xt-border-highlight)) calc(var(--xt-highlight-own-opacity,1) * 100%),transparent) !important;background-color:var(--xt-post-background,var(--xt-background,transparent)) !important}.styling-preview[data-highlight-you="true"][data-edge-you="true"] .styling-preview-post.quotesYou>.reply{border-left:var(--xt-edge-width-you,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-you,solid) color-mix(in srgb,var(--xt-highlight-you,var(--xt-border-highlight)) calc(var(--xt-highlight-you-opacity,1) * 100%),transparent) !important;background-color:var(--xt-post-background,var(--xt-background,transparent)) !important}.styling-preview[data-highlight-ghost="true"][data-edge-ghost="true"] .styling-preview-post.from-archive>.reply{border-left:var(--xt-edge-width-ghost,var(--xt-highlight-edge-width,3px)) var(--xt-edge-style-ghost,dotted) color-mix(in srgb,var(--xt-highlight-ghost,#888) calc(var(--xt-highlight-ghost-opacity,1) * 100%),transparent) !important;background-color:var(--xt-post-background,var(--xt-background,transparent)) !important}.styling-preview[data-highlight-own="false"] .styling-preview-post.yourPost>.reply,.styling-preview[data-highlight-you="false"] .styling-preview-post.quotesYou>.reply,.styling-preview[data-highlight-ghost="false"] .styling-preview-post.from-archive>.reply{opacity:.6}.styling-preview .styling-preview-post.filter-highlight>.reply{box-shadow:inset 5px 0 var(--xt-filter-highlight,rgba(221,0,0,.5))}.styling-preview .styling-preview-post.filter-highlight>.sideArrows{color:var(--xt-highlight-side-arrow,rgba(221,0,0,.8))}.styling-preview .styling-preview-post.yourPost .postInfo,.styling-preview .styling-preview-post.yourPost .postMessage{color:var(--xt-highlight-own-text,var(--xt-text-color))}.styling-preview .styling-preview-post.yourPost a{color:var(--xt-highlight-own-link,var(--xt-link-text-color)) !important}.styling-preview .styling-preview-post.yourPost .quote{color:var(--xt-highlight-own-quote,var(--xt-quote-text-color,var(--xt-quote-color,#789922))) !important}.styling-preview .styling-preview-post.yourPost .backlink.deadlink,.styling-preview .styling-preview-post.yourPost .quotelink.deadlink{color:var(--xt-highlight-own-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}.styling-preview .styling-preview-post.quotesYou .postInfo,.styling-preview .styling-preview-post.quotesYou .postMessage{color:var(--xt-highlight-you-text,var(--xt-text-color))}.styling-preview .styling-preview-post.quotesYou a{color:var(--xt-highlight-you-link,var(--xt-link-text-color)) !important}.styling-preview .styling-preview-post.quotesYou .quote{color:var(--xt-highlight-you-quote,var(--xt-quote-text-color,var(--xt-quote-color,#789922))) !important}.styling-preview .styling-preview-post.quotesYou .backlink.deadlink,.styling-preview .styling-preview-post.quotesYou .quotelink.deadlink{color:var(--xt-highlight-you-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}.styling-preview .styling-preview-post.from-archive .postInfo,.styling-preview .styling-preview-post.from-archive .postMessage{color:var(--xt-highlight-ghost-text,var(--xt-text-color))}.styling-preview .styling-preview-post.from-archive a{color:var(--xt-highlight-ghost-link,var(--xt-link-text-color)) !important}.styling-preview .styling-preview-post.from-archive .quote{color:var(--xt-highlight-ghost-quote,var(--xt-quote-text-color,var(--xt-quote-color,#789922))) !important}.styling-preview .styling-preview-post.from-archive .backlink.deadlink,.styling-preview .styling-preview-post.from-archive .quotelink.deadlink{color:var(--xt-highlight-ghost-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}.styling-preview[data-highlight-catalog-own="true"]:not([data-edge-catalog-own="true"]):not([data-loc-catalog-own="image"]) .catalog-thread:is(:has(.xtcat-own),.xtcat-own){background:color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own)) calc(var(--xt-catalog-own-highlight-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,#d6daf0))) !important;overflow:hidden}.styling-preview[data-highlight-catalog-own="true"][data-edge-catalog-own="true"]:not([data-loc-catalog-own="image"]) .catalog-thread:is(:has(.xtcat-own),.xtcat-own){border-color:color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own,var(--xt-border-highlight))) calc(var(--xt-catalog-own-highlight-opacity,1) * 100%),transparent) !important;border-style:var(--xt-catalog-border-style-own,solid) !important;border-width:var(--xt-catalog-border-width-own,var(--xt-catalog-border-width,3px)) !important}.styling-preview[data-highlight-catalog-own="true"][data-edge-catalog-own="true"][data-loc-catalog-own="image"] .catalog-thread:is(:has(.xtcat-own),.xtcat-own) .catalog-thumb{border:var(--xt-catalog-border-width-own,var(--xt-catalog-border-width,3px)) var(--xt-catalog-border-style-own,solid) color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own,var(--xt-border-highlight))) calc(var(--xt-catalog-own-highlight-opacity,1) * 100%),transparent) !important;box-sizing:border-box}.styling-preview[data-highlight-catalog-own="true"]:not([data-edge-catalog-own="true"])[data-loc-catalog-own="image"] .catalog-thread:is(:has(.xtcat-own),.xtcat-own) .catalog-thumb{background:color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own)) calc(var(--xt-catalog-own-highlight-opacity,1) * 100%),var(--xt-post-background,var(--xt-background,#d6daf0))) !important;box-sizing:border-box;padding:var(--xt-catalog-border-width-own,var(--xt-catalog-border-width,3px)) !important}.styling-preview[data-highlight-catalog-own="true"]:not([data-edge-catalog-own="true"]) :is( .catalog-thread:is(:has(.xtcat-own),.xtcat-own)>.catalog-container,.catalog-thread>.catalog-container.xtcat-own,.catalog-thread:has(.xtcat-own) .post.catalog-post.xtcat-own,.catalog-thread.xtcat-own .post.catalog-post,.catalog-thread>.catalog-container.xtcat-own .post.catalog-post ){background:transparent !important;border-color:transparent !important}.styling-preview[data-highlight-catalog-own="true"][data-text-catalog-own="true"] :is( .catalog-thread:has(.xtcat-own) .post.catalog-post.xtcat-own,.catalog-thread.xtcat-own .post.catalog-post,.catalog-thread>.catalog-container.xtcat-own .post.catalog-post ){color:var(--xt-catalog-own-text,var(--xt-text-color)) !important}.styling-preview[data-highlight-catalog-own="true"][data-text-catalog-own="true"] :is(.catalog-thread.xtcat-own,.catalog-thread>.catalog-container.xtcat-own) .post.catalog-post :is(.postInfo,.postMessage,.summary,.catalog-stats){color:var(--xt-catalog-own-text,var(--xt-text-color)) !important}.styling-preview[data-highlight-catalog-own="true"][data-text-catalog-own="true"] :is(.catalog-thread.xtcat-own,.catalog-thread>.catalog-container.xtcat-own) .post.catalog-post .subject{color:var(--xt-catalog-own-subject,var(--xt-catalog-own-text,var(--xt-text-color))) !important}.styling-preview[data-highlight-catalog-own="true"][data-text-catalog-own="true"] :is(.catalog-thread.xtcat-own,.catalog-thread>.catalog-container.xtcat-own) .post.catalog-post a{color:var(--xt-catalog-own-link,var(--xt-link-text-color)) !important}.styling-preview[data-highlight-catalog-own="true"][data-text-catalog-own="true"] :is(.catalog-thread.xtcat-own,.catalog-thread>.catalog-container.xtcat-own) .post.catalog-post .quote{color:var(--xt-catalog-own-quote,var(--xt-quote-text-color)) !important}.styling-preview[data-highlight-catalog-own="true"][data-text-catalog-own="true"] :is(.catalog-thread.xtcat-own,.catalog-thread>.catalog-container.xtcat-own) .post.catalog-post .deadlink{color:var(--xt-catalog-own-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}.styling-preview[data-highlight-catalog-watched="true"]:not([data-edge-catalog-watched="true"]):not([data-loc-catalog-watched="image"]) .catalog-thread.xtcat-watched{background:color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) calc(var(--xt-catalog-watched-highlight-opacity,0.2) * 100%),var(--xt-post-background,var(--xt-background,#d6daf0))) !important;overflow:hidden}.styling-preview[data-highlight-catalog-watched="true"][data-edge-catalog-watched="true"]:not([data-loc-catalog-watched="image"]) .catalog-thread.xtcat-watched{border-color:color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) calc(var(--xt-catalog-watched-highlight-opacity,0.2) * 100%),transparent) !important;border-style:var(--xt-catalog-border-style-watched,solid) !important;border-width:var(--xt-catalog-border-width-watched,var(--xt-catalog-border-width,3px)) !important}.styling-preview[data-highlight-catalog-watched="true"][data-edge-catalog-watched="true"][data-loc-catalog-watched="image"] .catalog-thread.xtcat-watched .catalog-thumb{border:var(--xt-catalog-border-width-watched,var(--xt-catalog-border-width,3px)) var(--xt-catalog-border-style-watched,solid) color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) calc(var(--xt-catalog-watched-highlight-opacity,0.2) * 100%),transparent) !important;box-sizing:border-box}.styling-preview[data-highlight-catalog-watched="true"]:not([data-edge-catalog-watched="true"])[data-loc-catalog-watched="image"] .catalog-thread.xtcat-watched .catalog-thumb{background:color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) calc(var(--xt-catalog-watched-highlight-opacity,0.2) * 100%),var(--xt-post-background,var(--xt-background,#d6daf0))) !important;box-sizing:border-box;padding:var(--xt-catalog-border-width-watched,var(--xt-catalog-border-width,3px)) !important}.styling-preview[data-highlight-catalog-watched="true"]:not([data-edge-catalog-watched="true"]) .catalog-thread.xtcat-watched>.catalog-container,.styling-preview[data-highlight-catalog-watched="true"]:not([data-edge-catalog-watched="true"]) .catalog-thread.xtcat-watched .post.catalog-post{background:transparent !important;border-color:transparent !important}.styling-preview[data-highlight-catalog-watched="true"][data-text-catalog-watched="true"] .catalog-thread.xtcat-watched .post.catalog-post{color:var(--xt-catalog-watched-text,var(--xt-text-color)) !important}.styling-preview[data-highlight-catalog-watched="true"][data-text-catalog-watched="true"] .catalog-thread.xtcat-watched .post.catalog-post :is(.postInfo,.postMessage,.summary,.catalog-stats){color:var(--xt-catalog-watched-text,var(--xt-text-color)) !important}.styling-preview[data-highlight-catalog-watched="true"][data-text-catalog-watched="true"] .catalog-thread.xtcat-watched .post.catalog-post .subject{color:var(--xt-catalog-watched-subject,var(--xt-catalog-watched-text,var(--xt-text-color))) !important}.styling-preview[data-highlight-catalog-watched="true"][data-text-catalog-watched="true"] .catalog-thread.xtcat-watched .post.catalog-post a{color:var(--xt-catalog-watched-link,var(--xt-link-text-color)) !important}.styling-preview[data-highlight-catalog-watched="true"][data-text-catalog-watched="true"] .catalog-thread.xtcat-watched .post.catalog-post .quote{color:var(--xt-catalog-watched-quote,var(--xt-quote-text-color)) !important}.styling-preview[data-highlight-catalog-watched="true"][data-text-catalog-watched="true"] .catalog-thread.xtcat-watched .post.catalog-post .deadlink{color:var(--xt-catalog-watched-dead-link,var(--xt-dead-link-text-color,var(--xt-dead-link))) !important}.styling-preview[data-glow-catalog-own="true"]:not([data-loc-catalog-own="image"]) .catalog-thread:is(:has(.xtcat-own),.xtcat-own){box-shadow:0 0 calc(var(--xt-catalog-own-glow-intensity,.5) * 6px) calc(var(--xt-catalog-own-glow-intensity,.5) * 6px) color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own)) 70%,transparent) !important}.styling-preview[data-glow-catalog-own="true"][data-loc-catalog-own="image"] .catalog-thread:is(:has(.xtcat-own),.xtcat-own) .catalog-thumb{box-shadow:0 0 calc(var(--xt-catalog-own-glow-intensity,.5) * 6px) calc(var(--xt-catalog-own-glow-intensity,.5) * 6px) color-mix(in srgb,var(--xt-catalog-own-highlight,var(--xt-highlight-own)) 70%,transparent) !important}.styling-preview[data-glow-catalog-watched="true"]:not([data-loc-catalog-watched="image"]) .catalog-thread.xtcat-watched{box-shadow:0 0 calc(var(--xt-catalog-watched-glow-intensity,.5) * 6px) calc(var(--xt-catalog-watched-glow-intensity,.5) * 6px) color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) 70%,transparent) !important}.styling-preview[data-glow-catalog-watched="true"][data-loc-catalog-watched="image"] .catalog-thread.xtcat-watched .catalog-thumb{box-shadow:0 0 calc(var(--xt-catalog-watched-glow-intensity,.5) * 6px) calc(var(--xt-catalog-watched-glow-intensity,.5) * 6px) color-mix(in srgb,var(--xt-catalog-watched-highlight,var(--xt-watched-border,rgba(255,0,0,.75))) 70%,transparent) !important}.styling-preview .catalog-thread.xtcat-filter .catalog-thumb{box-shadow:0 0 3px 3px var(--xt-highlight-shadow,rgba(255,0,0,.5))}.section-styling .styling-variant-bar{align-items:center;border-bottom:1px solid rgba(128,128,128,.25);display:flex;flex-wrap:wrap;gap:10px 14px;margin:0 0 10px;padding:6px 8px 8px;position:relative}.section-styling .styling-variant-bar[data-editing-variant="nsfw"]{--xt-variant-accent:#a66a00}.section-styling .styling-variant-mode{align-items:center;display:inline-flex;gap:6px}.section-styling .styling-variant-tabs{display:inline-flex;gap:4px}.section-styling .styling-variant-tabs .settings-subnav-tab{background:transparent;border:1px solid;border-radius:3px;color:inherit;cursor:pointer;font:inherit;line-height:1.2;padding:2px 10px}.section-styling .styling-variant-tabs .settings-subnav-tab-selected{background:var(--xt-variant-accent,rgba(128,128,128,.25));border-color:var(--xt-variant-accent,currentColor);color:#fff;font-weight:700}.section-styling .styling-variant-hint{flex:1 1 100%;font-size:11px;opacity:.85}.section-styling .styling-variant-hint:empty{display:none}.section-styling .styling-variant-bar[data-mode="sfw"] .styling-variant-tabs,.section-styling .styling-variant-bar[data-mode="nsfw"] .styling-variant-tabs,.section-styling .styling-variant-bar[data-mode="sfw"] .styling-variant-hint,.section-styling .styling-variant-bar[data-mode="nsfw"] .styling-variant-hint{display:none}.styling-section-summary-label{display:inline-flex;align-items:center;gap:.45em;min-width:0}.styling-section-toggle{flex:0 0 auto;display:inline-flex;align-items:center;margin:0;cursor:pointer}.styling-section-toggle>input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}.styling-section-toggle-box{box-sizing:border-box;display:inline-block;width:15px;height:15px;border:1px solid currentColor;border-radius:3px;opacity:.85;background:transparent;position:relative}.styling-section-toggle>input:checked + .styling-section-toggle-box{background:var(--xt-variant-accent,#2d7d46);border-color:var(--xt-variant-accent,#2d7d46);opacity:1}.styling-section-toggle>input:checked + .styling-section-toggle-box::after{content:"";position:absolute;left:4px;top:1px;width:4px;height:8px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg)}.styling-section-toggle>input:focus-visible + .styling-section-toggle-box{outline:2px solid var(--xt-variant-accent,#2d7d46);outline-offset:1px}details[data-styling-section].styling-section-off>:not(summary){opacity:.45}details[data-styling-section].styling-section-off>summary{opacity:.7}body>#overlay:not(.media-preview){z-index:1003 !important}.styling-extstyle-box{border:1px solid rgba(128,128,128,.35);border-radius:4px;display:flex;flex-direction:column;gap:8px;margin:0 0 12px;padding:10px 12px}.styling-extstyle-text{font-size:12px;line-height:1.4}.styling-extstyle-buttons{display:flex;flex-wrap:wrap;gap:8px}.styling-extstyle-buttons>button{cursor:pointer;padding:6px 12px}#xt-settings-overlay .section-styling{position:relative}#xt-settings-overlay .section-styling[data-editing-variant="nsfw"]{--xt-variant-accent:#a66a00}#xt-settings-overlay .section-styling details[data-variant-aware="true"]>summary .styling-section-summary-text::after{background:color-mix(in srgb,var(--xt-variant-accent,currentColor) 16%,transparent);border:1px solid color-mix(in srgb,var(--xt-variant-accent,currentColor) 45%,transparent);border-radius:3px;color:inherit;content:attr(data-variant-label);display:inline-block;font-size:10px;font-weight:700;line-height:1.2;margin-left:8px;padding:1px 5px;vertical-align:1px}#xt-settings-overlay .section-styling details[data-variant-aware="true"]>summary>.styling-variant-badge{background:color-mix(in srgb,var(--xt-variant-accent,currentColor) 16%,transparent);border:1px solid color-mix(in srgb,var(--xt-variant-accent,currentColor) 45%,transparent);border-radius:3px;color:inherit;display:inline-block;flex:0 0 auto;font-size:10px;font-weight:700;line-height:1.2;margin-left:8px;margin-right:auto;padding:1px 5px;vertical-align:1px}.section-styling .styling-theme-picker>select[name^="siteStyle"]{display:none !important}.section-styling .styling-theme-row{align-items:center;display:flex;flex-wrap:wrap;gap:10px 14px}.section-styling .styling-theme-row>label{align-items:center;display:inline-flex;gap:6px}.section-styling .styling-theme-row select{min-width:180px}.section-styling .styling-theme-picker{position:relative;display:inline-block;min-width:200px}.section-styling .styling-theme-toggle{align-items:center;background:transparent;border:1px solid rgba(128,128,128,.45);border-radius:3px;color:inherit;cursor:pointer;display:inline-flex;gap:8px;justify-content:space-between;min-width:200px;padding:3px 8px;text-align:left;width:100%}.section-styling .styling-theme-toggle:disabled{cursor:default;opacity:.5}.section-styling .styling-theme-current{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.section-styling .styling-theme-caret{flex:0 0 auto;opacity:.65}.section-styling .styling-theme-picker[data-open="true"] .styling-theme-caret{transform:rotate(180deg)}.section-styling .styling-theme-menu{background:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));border:1px solid rgba(128,128,128,.45);border-radius:3px;box-shadow:0 4px 12px rgba(0,0,0,.18);left:0;max-height:260px;min-width:100%;overflow-y:auto;padding:2px 0;position:absolute;top:calc(100% + 2px);z-index:200}.section-styling .styling-theme-item{align-items:center;cursor:pointer;display:flex;gap:6px;justify-content:space-between;padding:4px 8px}.section-styling .styling-theme-item:hover{background:color-mix(in srgb,currentColor 12%,transparent)}.section-styling .styling-theme-item-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.section-styling .styling-theme-item-remove{background:transparent;border:0;color:inherit;cursor:pointer;flex:0 0 auto;font-size:12px;line-height:1;opacity:.55;padding:2px 4px}.section-styling .styling-theme-item-remove:hover{background:color-mix(in srgb,currentColor 20%,transparent);border-radius:2px;opacity:1}.section-styling .styling-theme-divider{border-top:1px solid rgba(128,128,128,.35);margin:4px 0}.section-styling [data-name]>.styling-tree-row{margin:6px 0 4px}.section-styling [data-name]>.styling-tree{border-left:1px solid color-mix(in srgb,currentColor 25%,transparent);margin:6px 0 0 10px;padding-left:8px}.section-styling [data-name="Highlight Colors"]>.styling-tree{border-left:0;margin-left:0;padding-left:0}.section-styling .styling-compact-control{margin:6px 0 8px}.section-styling .styling-compact-control label{align-items:center;display:inline-flex;gap:8px}.section-styling div[data-checked="false"]>.styling-tree{display:none}.section-styling .styling-inline-option{align-items:center;display:flex;gap:8px;justify-content:space-between;border-radius:3px;padding:2px 4px}.section-styling .styling-inline-option:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.section-styling .styling-inline-option.styling-match-hover-target{background:color-mix(in srgb,currentColor 14%,transparent);outline:1px solid color-mix(in srgb,currentColor 30%,transparent);outline-offset:-1px}.section-styling .styling-group-label{font-weight:600;margin:8px 4px 3px;opacity:.9}.section-styling .styling-inline-option>label{align-items:center;display:flex;flex:1 1 auto;gap:4px;min-width:0}.section-styling .styling-inline-option>label::after{content:"";border-bottom:1px dotted;flex:1 1 auto;margin-left:8px;opacity:.35}.section-styling :is(.styling-inline-controls,.styling-inline-toggle){align-items:center;display:inline-flex;gap:4px}.section-styling .styling-inline-controls input[type="color"]{height:22px;width:46px;padding:0;border:1px solid rgba(128,128,128,.35);background:transparent}.section-styling .styling-color-hex,.section-styling .styling-saved-palette-name,.section-styling .styling-hl-widthnum{background:transparent;border:1px solid rgba(128,128,128,.45);border-radius:3px;color:inherit}.section-styling :is(.styling-variant-mode,.styling-theme-row,.styling-hl-octl,.custom-css-controls) select{background:transparent;border:1px solid rgba(128,128,128,.45);border-radius:3px;color:inherit;padding:2px 6px}.section-styling :is(.styling-variant-mode,.styling-theme-row,.styling-hl-octl,.custom-css-controls) select option{background:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));color:inherit}.section-styling .styling-inline-controls .styling-color-hex{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:11px;height:22px;padding:1px 4px;text-transform:lowercase;width:70px}.section-styling .styling-inline-controls .styling-color-hex-invalid{border-color:#d33;box-shadow:0 0 0 1px color-mix(in srgb,#d33 45%,transparent)}.section-styling .styling-inline-controls input[type="range"]{width:156px}.section-styling .styling-highlight-controls{flex-wrap:nowrap;gap:6px}.section-styling .styling-highlight-controls .styling-highlight-manual{align-items:center;display:inline-flex;gap:4px}.section-styling .styling-highlight-controls .styling-highlight-manual input[type="color"]{width:34px}.section-styling .styling-inline-option[data-color-linked="true"] .styling-inline-controls input[type="color"],.section-styling .styling-inline-option[data-color-linked="true"] .styling-inline-controls button[data-clear]{opacity:.45}.section-styling .styling-hl-group{margin:10px 0 8px}.section-styling .styling-hl-grouphead{align-items:center;display:flex;flex-wrap:wrap;gap:6px 16px;margin:6px 0}#xt-settings-overlay .section-styling .styling-hl-master{align-items:center;display:inline-flex;font-weight:600;gap:5px;line-height:16px;min-height:18px;white-space:nowrap}#xt-settings-overlay .section-styling .styling-hl-grouptitle{font-weight:600;line-height:16px;min-height:18px;white-space:nowrap}#xt-settings-overlay .section-styling .styling-hl-master input[type="checkbox"]{align-self:center;display:block;flex:none;margin:0;position:relative;top:1px}.section-styling .styling-hl-width{align-items:center;display:inline-flex;gap:6px;margin-left:auto;opacity:.85}.section-styling .styling-hl-width input[type="range"]{width:92px}.section-styling .styling-hl-acc-item{background:rgba(128,128,128,0.05);border:1px solid rgba(128,128,128,0.18);border-radius:8px;margin:6px 0;overflow:hidden;transition:all 0.2s cubic-bezier(0.4,0,0.2,1);box-shadow:0 1px 2px rgba(0,0,0,0.05)}.section-styling .styling-hl-acc-item.styling-hl-open{background:rgba(128,128,128,0.08);border-color:rgba(128,128,128,0.35);box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:10px 0}.section-styling .styling-hl-acc-head{align-items:center;cursor:pointer;display:flex;gap:10px;padding:6px 12px;user-select:none;transition:background-color 0.2s}.section-styling .styling-hl-acc-head:hover{background:rgba(128,128,128,0.12)}.section-styling .styling-hl-acc-item.styling-hl-open .styling-hl-acc-head{background:rgba(128,128,128,0.1);border-bottom:1px solid rgba(128,128,128,0.2)}.section-styling .styling-hl-acc-item.styling-hl-open .styling-hl-acc-head:hover{background:rgba(128,128,128,0.15)}.section-styling .styling-hl-acc-name{align-items:center;display:flex;flex:1 1 auto;font-weight:600;gap:6px;min-width:0;font-size:12.5px}.section-styling .styling-hl-type-note{font-size:10px;font-weight:400;opacity:.55}.section-styling .styling-hl-acc-chev{background:none;border:0;color:inherit;cursor:pointer;font-size:12px;opacity:.55;padding:0 2px;transition:transform .15s}.section-styling .styling-hl-acc-item.styling-hl-open .styling-hl-acc-chev{transform:rotate(90deg)}.section-styling .styling-hl-acc-body{display:none;padding:10px 14px 12px;border-top:1px solid rgba(128,128,128,0.05)}.section-styling .styling-hl-acc-item.styling-hl-open .styling-hl-acc-body{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:8px 20px;background:rgba(128,128,128,0.03)}#xt-settings-overlay .section-styling .styling-hl-sub{align-items:center;border-bottom:1px solid rgba(128,128,128,0.25);box-sizing:border-box;display:flex;font-size:11px;gap:6px;letter-spacing:.05em;line-height:16px;margin:8px 0 2px;min-height:21px;padding:2px 0;grid-column:1 / -1}.section-styling .styling-hl-subtoggle{cursor:pointer}.section-styling .styling-hl-subtoggle input[type="checkbox"]{align-self:center;display:block;flex:none;margin:0;position:relative;top:1px}.section-styling .styling-hl-sub-text{align-items:center;display:inline-flex;line-height:16px;min-height:16px;opacity:.7;font-weight:700;text-transform:uppercase}.section-styling .styling-hl-rowactions{border-bottom:0;justify-content:flex-end;padding-top:6px;grid-column:1 / -1}.section-styling .styling-hl-rowactions button{font-size:11px}.section-styling .styling-hl-orow{align-items:center;background:rgba(128,128,128,0.065);border-bottom:1px solid rgba(128,128,128,0.1);border-radius:4px;display:flex;gap:10px;justify-content:space-between;margin:0 -4px;padding:4px 4px;box-shadow:0 1px 2px rgba(0,0,0,0.08);transition:background-color 0.15s,box-shadow 0.15s,transform 0.15s}.section-styling .styling-hl-orow:last-child{border-bottom:0}.section-styling .styling-hl-orow:hover{background:rgba(128,128,128,0.12);box-shadow:0 2px 4px rgba(0,0,0,0.13);transform:scale(1.005)}.section-styling .styling-hl-olabel{min-width:0;font-size:11.5px;font-weight:500}.section-styling .styling-hl-octl{align-items:center;display:inline-flex;gap:6px}.section-styling .styling-hl-color{align-items:center;display:inline-flex;gap:6px}.section-styling .styling-hl-mkheadslot{display:contents}.section-styling .styling-hl-color input[type="color"],.section-styling .styling-hl-octl input[type="color"]{background:transparent;border:1.5px solid rgba(128,128,128,.4);border-radius:50%;cursor:pointer;height:22px;padding:0;width:22px;transition:transform 0.15s,border-color 0.15s}.section-styling .styling-hl-color input[type="color"]:hover,.section-styling .styling-hl-octl input[type="color"]:hover{transform:scale(1.15);border-color:rgba(128,128,128,.75)}.section-styling .styling-hl-color input[type="color"]::-webkit-color-swatch-wrapper,.section-styling .styling-hl-octl input[type="color"]::-webkit-color-swatch-wrapper{padding:0}.section-styling .styling-hl-color input[type="color"]::-webkit-color-swatch,.section-styling .styling-hl-octl input[type="color"]::-webkit-color-swatch{border:none;border-radius:50%}.section-styling .styling-hl-octl input[type="range"]{-webkit-appearance:none;appearance:none;background:rgba(128,128,128,0.2);border-radius:4px;height:5px;outline:none;width:130px}.section-styling .styling-hl-octl input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background:currentColor;border:1px solid rgba(255,255,255,0.8);border-radius:50%;cursor:pointer;height:12px;width:12px;box-shadow:0 1px 3px rgba(0,0,0,0.3);transition:transform 0.1s}.section-styling .styling-hl-octl input[type="range"]::-webkit-slider-thumb:hover{transform:scale(1.2)}.section-styling .styling-hl-widthnum{text-align:right;width:56px}.section-styling .styling-hl-unit{font-size:11px;opacity:.7}.section-styling .styling-hl-valout{font-size:11px;font-variant-numeric:tabular-nums;min-width:34px;opacity:.75;text-align:right}.section-styling .styling-hl-octl select{max-width:110px}.section-styling .styling-hl-color .styling-color-hex,.section-styling .styling-hl-octl .styling-color-hex{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:11px;height:22px;padding:1px 4px;text-transform:lowercase;width:70px}.section-styling .styling-color-hex-invalid{border-color:#d33;box-shadow:0 0 0 1px color-mix(in srgb,#d33 45%,transparent)}.section-styling .styling-hl-inline-preview{margin-top:10px;grid-column:1 / -1;display:flex;flex-direction:column;gap:6px}.section-styling .styling-hl-preview-title{font-size:10px;font-weight:700;text-transform:uppercase;opacity:0.55;letter-spacing:0.05em;margin-bottom:2px}.section-styling .styling-hl-inline-preview .styling-preview-thread,.section-styling .styling-hl-inline-preview .styling-preview-catalog{border:1px solid rgba(128,128,128,0.2);border-radius:6px;max-width:100%;overflow:hidden}.section-styling .styling-hl-group-catalog .styling-hl-acc-item.styling-hl-open .styling-hl-acc-body{display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px 20px}.section-styling .styling-hl-group-catalog .styling-hl-controls{flex:1 1 320px;min-width:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px 20px}.section-styling .styling-hl-group-catalog .styling-hl-inline-preview{flex:0 0 auto;margin-top:0}.section-styling .styling-color-merged{align-items:stretch;border:1px solid rgba(128,128,128,.45);border-radius:3px;display:inline-flex;height:22px;overflow:hidden;vertical-align:middle}.section-styling .styling-color-merged .styling-color-hex{background:transparent !important;background-color:transparent !important;border:0;border-radius:0;height:100%;margin:0;text-align:center;width:72px}.section-styling .styling-color-merged .styling-color-hex:focus{outline:1px solid rgba(255,255,255,.65);outline-offset:-2px}.section-styling .styling-color-merged .styling-color-trigger{background:transparent !important;background-color:transparent !important;border-left:1px solid rgba(128,128,128,.55);cursor:pointer;flex:none;position:relative;width:18px}.section-styling .styling-color-merged .styling-color-trigger input[type="color"]{background:transparent !important;background-color:transparent !important;border:0;cursor:pointer;height:100%;inset:0;margin:0;opacity:0;padding:0;position:absolute;width:100%}.section-styling .styling-color-merged:has(.styling-color-hex:disabled){opacity:.5;transform:translateZ(0)}.section-styling .styling-hl-inlinecolors{align-items:center;display:inline-flex;gap:3px}.section-styling .styling-hl-inlinecolors[hidden]{display:none}.section-styling .styling-hl-inlinecolors input[type="color"]{background:transparent;border:1px solid rgba(128,128,128,.35);height:22px;padding:0;width:24px}.section-styling .styling-hl-mkcolor[data-color-linked="true"] .styling-color-merged{opacity:.45;transform:translateZ(0)}.section-styling .styling-hl-acc input:disabled,.section-styling .styling-hl-acc select:disabled{opacity:.5}.section-styling .styling-hl-orow:has(input:disabled,select:disabled) .styling-hl-olabel{opacity:.4}.section-styling .custom-css-controls{align-items:center;display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}.section-styling .custom-css-toggle-row{align-items:center;display:flex;flex-wrap:wrap;gap:8px 14px;padding:6px 0}.section-styling .custom-css-toggle-row>label{align-items:center;display:inline-flex;gap:4px}.section-styling .custom-css-note{margin:0 0 8px;opacity:.85}.section-styling .custom-css-controls>label{align-items:center;display:inline-flex;gap:4px}.section-styling .custom-css-summary-detach{font-weight:400;flex:0 0 auto;margin-left:auto}.section-styling .styling-variant-badge + .custom-css-summary-detach{margin-left:0}.section-styling .custom-css-controls>.ccss-controls-right{margin-left:auto}.section-styling .ccss-format-opts{position:relative}.section-styling .ccss-format-gear{align-items:center;display:inline-flex;justify-content:center;line-height:1;padding:2px 6px}.section-styling .ccss-format-opts[data-open="true"] .ccss-format-gear{background:rgba(128,128,128,.25)}.section-styling .ccss-format-popover{background:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));border:1px solid rgba(128,128,128,.45);border-radius:5px;box-shadow:0 4px 14px rgba(0,0,0,.25);display:flex;flex-direction:column;gap:8px;left:0;min-width:220px;padding:10px;position:absolute;top:100%;margin-top:4px;z-index:200}.section-styling .ccss-format-title{border-bottom:1px solid rgba(128,128,128,.3);font-weight:700;margin:-2px 0 2px;opacity:.85;padding-bottom:6px}.section-styling .ccss-format-row{align-items:center;display:flex;gap:6px;white-space:nowrap}.section-styling .ccss-format-row>select{margin-left:auto}.section-styling .ccss-shortcuts-toggle{align-items:center;display:inline-flex;justify-content:center;line-height:1;padding:2px 6px}.section-styling .ccss-shortcuts-toggle[aria-expanded="true"]{background:rgba(128,128,128,.25)}.section-styling .custom-css-shortcuts{align-items:center;display:flex;flex-wrap:wrap;gap:5px 12px;margin:-2px 0 8px;opacity:.86}.section-styling .custom-css-shortcuts>span{align-items:center;display:inline-flex;gap:2px;white-space:nowrap}.section-styling .custom-css-shortcuts kbd{border:1px solid rgba(128,128,128,.45);border-bottom-color:rgba(128,128,128,.6);border-radius:3px;box-shadow:inset 0 -1px 0 rgba(0,0,0,.08);font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;min-width:1.4em;padding:0 4px;text-align:center}.custom-css-editor{--custom-css-bg:#fbfbfb;--custom-css-text:#1f2731;--custom-css-caret:#1f2731;--custom-css-border:rgba(128,128,128,.35);--custom-css-bracket-bg:rgba(255,214,76,.42);--custom-css-bracket-border:rgba(166,118,0,.65);--custom-css-bracket-error-bg:rgba(220,38,38,.28);--custom-css-bracket-error-border:rgba(190,18,60,.72);--custom-css-font:12px/1.42 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--ccss-gutter-w:40px;--ccss-pad-l:44px;background:var(--custom-css-bg);border:1px solid var(--custom-css-border);border-radius:4px;height:180px;min-height:180px;overflow:hidden;position:relative;resize:vertical}.section-styling .custom-css-saved,#xt-settings-overlay .settings-textarea-saved{position:absolute;top:6px;right:8px;z-index:2;padding:2px 9px;border-radius:3px;font:600 11px/1.5 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;color:#fff;background:rgba(46,160,67,.94);pointer-events:none;opacity:0;transform:translateY(-2px);transition:opacity .12s ease,transform .12s ease}.section-styling .custom-css-saved[data-show="true"],#xt-settings-overlay .settings-textarea-saved[data-show="true"]{opacity:1;transform:translateY(0)}#xt-settings-overlay .custom-css-panel{position:fixed;z-index:1500;display:flex !important;flex-direction:column;min-width:360px;min-height:240px;max-width:100vw;max-height:100vh;overflow:hidden;resize:both;border-radius:6px;border:1px solid var(--custom-css-border,rgba(128,128,128,.35));background:var(--custom-css-bg,#fbfbfb);box-shadow:0 12px 48px rgba(0,0,0,.5)}#xt-settings-overlay .custom-css-panel-bar{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 6px 6px 12px;cursor:move;user-select:none;font:600 12px/1.6 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;color:var(--custom-css-text,#1f2731);border-bottom:1px solid var(--custom-css-border,rgba(128,128,128,.35))}#xt-settings-overlay .custom-css-panel-actions{display:flex;align-items:center;gap:6px}#xt-settings-overlay .custom-css-panel-reset{cursor:pointer}#xt-settings-overlay .custom-css-panel-close{cursor:pointer;padding:0 8px;font-size:16px;line-height:1.2}#xt-settings-overlay .custom-css-panel-body{flex:1 1 auto !important;min-height:0;display:flex !important;flex-direction:column;overflow:auto;padding:10px 12px 12px}#xt-settings-overlay .custom-css-panel-body>.styling-custom-css{display:flex !important;flex-direction:column;flex:1 1 auto !important;min-height:0;margin:0 !important;padding:0 !important;border:0 !important}#xt-settings-overlay .custom-css-panel-body>.styling-custom-css::details-content{display:flex !important;flex-direction:column !important;flex:1 1 auto !important;min-height:0 !important}#xt-settings-overlay .custom-css-panel-body .custom-css-editor{flex:1 1 auto !important;height:auto !important;min-height:120px !important;resize:none}#xt-settings-overlay .styling-custom-css[data-detached]>summary{display:none !important}.custom-css-detached-note{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px dashed rgba(128,128,128,.5);border-radius:4px;opacity:.85}.custom-css-reattach{cursor:pointer}.xt-detach-btn{cursor:pointer}.xt-detach-summary-btn{font-weight:400;flex:0 0 auto;margin-left:auto}#xt-settings-overlay .xt-detach-panel{--xt-detach-border:var(--xt-border,rgba(128,128,128,.45));position:fixed;z-index:1500;display:flex !important;flex-direction:column;min-width:320px;min-height:200px;max-width:100vw;max-height:100vh;overflow:hidden;resize:both;border-radius:6px;border:1px solid var(--xt-detach-border);background:var(--xt-background,Canvas);color:inherit;box-shadow:0 12px 48px rgba(0,0,0,.5)}#xt-settings-overlay .xt-detach-panel-bar{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 6px 6px 12px;cursor:move;user-select:none;font-weight:700;border-bottom:1px solid var(--xt-detach-border)}#xt-settings-overlay .xt-detach-panel-actions{display:flex;align-items:center;gap:6px}#xt-settings-overlay .xt-detach-panel-reset{cursor:pointer}#xt-settings-overlay .xt-detach-panel-close{cursor:pointer;padding:0 8px;font-size:16px;line-height:1.2}#xt-settings-overlay .xt-detach-panel-body{flex:1 1 auto !important;min-height:0;display:flex !important;flex-direction:column;overflow:auto;padding:10px 12px 12px}#xt-settings-overlay .xt-detach-panel-body>details,#xt-settings-overlay .xt-detach-panel-body>.sauce-section,#xt-settings-overlay .xt-detach-panel-body>.boardnav-detachable{display:flex !important;flex-direction:column;flex:1 1 auto !important;min-height:0;margin:0 !important}#xt-settings-overlay .xt-detach-panel-body>.boardnav-detachable>.boardnav-field-wrap{display:flex;flex-direction:column;flex:1 1 auto;min-height:0}#xt-settings-overlay .xt-detach-panel-body>details::details-content{display:flex !important;flex-direction:column !important;flex:1 1 auto !important;min-height:0 !important}#xt-settings-overlay .xt-detach-panel-body textarea{flex:1 1 auto !important;height:auto !important;min-height:120px !important;width:100%;resize:none}#xt-settings-overlay .xt-detach-panel-body .settings-textarea-shell{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}#xt-settings-overlay .xt-detach-panel-body .settings-textarea-shell>textarea{flex:1 1 auto !important;height:auto !important;min-height:120px !important}#xt-settings-overlay .xt-detach-panel-body .settings-textarea-resizer{display:none !important}#xt-settings-overlay details[data-detached]>summary{display:none !important}.xt-detach-note{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px dashed rgba(128,128,128,.5);border-radius:4px;opacity:.85}.xt-detach-reattach{cursor:pointer}.section-styling .custom-css-editor[data-theme="xt-dark"],.section-styling .custom-css-editor[data-theme="xt-system-tomorrow"],.section-styling .custom-css-editor[data-theme="xt-system-spooky"]{--custom-css-bg:#11161f;--custom-css-text:#d6deeb;--custom-css-caret:#d6deeb;--custom-css-border:rgba(143,164,194,.45);--custom-css-bracket-bg:rgba(255,214,76,.26);--custom-css-bracket-border:rgba(255,214,76,.65);--custom-css-bracket-error-bg:rgba(248,81,73,.26);--custom-css-bracket-error-border:rgba(248,81,73,.7);color-scheme:dark}.section-styling .custom-css-editor[data-theme="xt-solarized"]{--custom-css-bg:#fdf6e3;--custom-css-text:#586e75;--custom-css-caret:#586e75;--custom-css-border:rgba(101,123,131,.45);--custom-css-bracket-bg:rgba(181,137,0,.26);--custom-css-bracket-border:rgba(181,137,0,.7);--custom-css-bracket-error-bg:rgba(220,50,47,.24);--custom-css-bracket-error-border:rgba(220,50,47,.72)}.section-styling .custom-css-editor[data-theme^="xt-system-"]{--custom-css-bg:var(--xt-background,#fbfbfb);--custom-css-text:var(--xt-menu-fg,var(--xt-header-dialog-fg,#1f2731));--custom-css-caret:var(--custom-css-text);--custom-css-border:rgba(128,128,128,.45)}.section-styling .custom-css-editor[data-theme="xt-system-default"]{--custom-css-bg:#fbfbfb;--custom-css-text:#1f2731;--custom-css-caret:#1f2731;--custom-css-border:rgba(128,128,128,.35)}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba"],[data-theme="xt-system-futaba"]){--custom-css-text:#6f3a29;--custom-css-caret:#6f3a29}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba-b"],[data-theme="xt-system-burichan"]){--custom-css-text:#2b355a;--custom-css-caret:#2b355a}.section-styling .custom-css-editor[data-theme="xt-system-photon"]{--custom-css-text:#2b2b2b;--custom-css-caret:#2b2b2b}.custom-css-editor>:is(.custom-css-highlight,.custom-css-textarea){background:var(--custom-css-bg) !important;box-sizing:border-box !important;font:var(--custom-css-font) !important;font-kerning:none !important;font-variant-ligatures:none !important;inset:0 0 12px 0 !important;letter-spacing:0 !important;margin:0 !important;overflow-wrap:normal !important;padding:8px !important;position:absolute !important;tab-size:2 !important;text-shadow:none !important;white-space:pre !important;word-break:normal !important}.custom-css-editor>.custom-css-highlight{border:0 !important;color:var(--custom-css-text) !important;display:block !important;overflow:hidden !important;pointer-events:none !important;z-index:0 !important}.custom-css-editor>.custom-css-textarea{background:transparent !important;border:0 !important;color:var(--custom-css-caret) !important;caret-color:var(--custom-css-caret);display:block !important;-webkit-text-fill-color:transparent !important;overflow:auto !important;overscroll-behavior:contain;pointer-events:auto !important;resize:none !important;z-index:2 !important}.custom-css-editor>.custom-css-textarea:focus{outline:none}.custom-css-editor>:is(.custom-css-highlight,.custom-css-textarea){padding-left:var(--ccss-pad-l) !important}.custom-css-editor>.custom-css-gutter{position:absolute;left:0;top:0;bottom:12px;width:var(--ccss-gutter-w);overflow:hidden;pointer-events:none;z-index:3}.custom-css-editor .custom-css-gutter-inner{position:absolute;inset:0 0 auto 0;will-change:transform}.custom-css-editor .custom-css-linenumbers{position:absolute;top:0;left:0;right:18px;font:var(--custom-css-font);color:var(--custom-css-text);opacity:.4;text-align:right;white-space:pre}.custom-css-editor .ccss-swatch{position:absolute;right:4px;width:13px;height:13px;margin:0;padding:0;border-radius:3px;border:1px solid rgba(128,128,128,.55);box-shadow:inset 0 0 0 1px rgba(255,255,255,.15);cursor:pointer;pointer-events:auto}.custom-css-editor>.ccss-color-input{position:fixed;width:1px;height:1px;padding:0;border:0;opacity:0;pointer-events:none}.custom-css-editor .ccss-deck{box-shadow:inset 0 0 0 1px rgba(255,255,255,.2),1px 1px 0 rgba(128,128,128,.5),2px 2px 0 rgba(128,128,128,.3)}.custom-css-editor>.ccss-swatch-fan{position:fixed;z-index:6;display:flex;gap:3px;pointer-events:none}.custom-css-editor>.ccss-swatch-fan[data-show="false"]{display:none}.custom-css-editor .ccss-swatch-fan .ccss-swatch{position:static;right:auto;top:auto;flex:none;pointer-events:auto;opacity:0;transform:translateX(calc(var(--ccss-fan-i,0) * -16px));animation:ccss-fan-in .14s ease forwards;animation-delay:calc(var(--ccss-fan-i,0) * .018s)}@keyframes ccss-fan-in{to{opacity:1;transform:translateX(0)}}.section-styling .custom-css-highlight .css-token-comment{color:#73838a;font-style:italic}.section-styling .custom-css-highlight .css-token-string{color:#a22b73}.section-styling .custom-css-highlight .css-token-atrule{color:#8b3fd9}.section-styling .custom-css-highlight .css-token-property{color:#005eb8}.section-styling .custom-css-highlight .css-token-number{color:#b23d24}.section-styling .custom-css-highlight .css-token-color{color:#0f8c57}.section-styling .custom-css-highlight .css-token-important{color:#b54a00;font-weight:700}.section-styling .custom-css-highlight :is(.css-token-bracket-match,.css-token-bracket-mismatch){border-radius:2px}.section-styling .custom-css-highlight .css-token-bracket-match{background:var(--custom-css-bracket-bg);box-shadow:inset 0 0 0 1px var(--custom-css-bracket-border)}.section-styling .custom-css-highlight .css-token-bracket-mismatch{background:var(--custom-css-bracket-error-bg);box-shadow:inset 0 0 0 1px var(--custom-css-bracket-error-border)}.section-styling .custom-css-editor:is([data-theme="xt-dark"],[data-theme="xt-system-tomorrow"],[data-theme="xt-system-spooky"]) .custom-css-highlight .css-token-comment{color:#7c8a99}.section-styling .custom-css-editor:is([data-theme="xt-dark"],[data-theme="xt-system-tomorrow"],[data-theme="xt-system-spooky"]) .custom-css-highlight .css-token-string{color:#f08ea3}.section-styling .custom-css-editor:is([data-theme="xt-dark"],[data-theme="xt-system-tomorrow"],[data-theme="xt-system-spooky"]) .custom-css-highlight .css-token-atrule{color:#c9a7ff}.section-styling .custom-css-editor:is([data-theme="xt-dark"],[data-theme="xt-system-tomorrow"],[data-theme="xt-system-spooky"]) .custom-css-highlight .css-token-property{color:#6fb3ff}.section-styling .custom-css-editor:is([data-theme="xt-dark"],[data-theme="xt-system-tomorrow"],[data-theme="xt-system-spooky"]) .custom-css-highlight .css-token-number{color:#ffb076}.section-styling .custom-css-editor:is([data-theme="xt-dark"],[data-theme="xt-system-tomorrow"],[data-theme="xt-system-spooky"]) .custom-css-highlight .css-token-color{color:#6dd08f}.section-styling .custom-css-editor:is([data-theme="xt-dark"],[data-theme="xt-system-tomorrow"],[data-theme="xt-system-spooky"]) .custom-css-highlight .css-token-important{color:#ffb46e}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba"],[data-theme="xt-system-futaba"]) .custom-css-highlight .css-token-comment{color:#8a6d61}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba"],[data-theme="xt-system-futaba"]) .custom-css-highlight .css-token-string{color:#9a315d}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba"],[data-theme="xt-system-futaba"]) .custom-css-highlight .css-token-atrule{color:#6f45bf}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba"],[data-theme="xt-system-futaba"]) .custom-css-highlight .css-token-property{color:#1764bc}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba"],[data-theme="xt-system-futaba"]) .custom-css-highlight .css-token-number{color:#b2571b}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba"],[data-theme="xt-system-futaba"]) .custom-css-highlight .css-token-color{color:#3b8649}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba"],[data-theme="xt-system-futaba"]) .custom-css-highlight .css-token-important{color:#b14a00}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba-b"],[data-theme="xt-system-burichan"],[data-theme="xt-system-photon"]) .custom-css-highlight .css-token-comment{color:#6d7893}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba-b"],[data-theme="xt-system-burichan"],[data-theme="xt-system-photon"]) .custom-css-highlight .css-token-string{color:#93468a}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba-b"],[data-theme="xt-system-burichan"],[data-theme="xt-system-photon"]) .custom-css-highlight .css-token-atrule{color:#5f57be}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba-b"],[data-theme="xt-system-burichan"],[data-theme="xt-system-photon"]) .custom-css-highlight .css-token-property{color:#1e60b7}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba-b"],[data-theme="xt-system-burichan"],[data-theme="xt-system-photon"]) .custom-css-highlight .css-token-number{color:#9d521e}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba-b"],[data-theme="xt-system-burichan"],[data-theme="xt-system-photon"]) .custom-css-highlight .css-token-color{color:#2f8654}.section-styling .custom-css-editor:is([data-theme="xt-system-yotsuba-b"],[data-theme="xt-system-burichan"],[data-theme="xt-system-photon"]) .custom-css-highlight .css-token-important{color:#a04f00}.section-styling .custom-css-editor[data-theme="xt-solarized"] .custom-css-highlight .css-token-comment{color:#93a1a1}.section-styling .custom-css-editor[data-theme="xt-solarized"] .custom-css-highlight .css-token-string{color:#2aa198}.section-styling .custom-css-editor[data-theme="xt-solarized"] .custom-css-highlight .css-token-atrule{color:#6c71c4}.section-styling .custom-css-editor[data-theme="xt-solarized"] .custom-css-highlight .css-token-property{color:#268bd2}.section-styling .custom-css-editor[data-theme="xt-solarized"] .custom-css-highlight .css-token-number{color:#b58900}.section-styling .custom-css-editor[data-theme="xt-solarized"] .custom-css-highlight .css-token-color{color:#859900}.section-styling .custom-css-editor[data-theme="xt-solarized"] .custom-css-highlight .css-token-important{color:#cb4b16}.section-sauce textarea{height:430px}.section-advanced .field.wide{width:100%}.section-advanced textarea{height:150px}.section-advanced textarea[name="archiveLists"],.section-advanced textarea[name="externalCatalogURLs"],.section-advanced textarea[name="knownBanners"]{height:75px}.section-advanced .archive-cell{min-width:160px;text-align:center}.section-advanced #archive-board-select{position:absolute}#fourchanx-settings.settings-compact-content .section-advanced #archive-board-select{box-sizing:border-box;display:block;max-width:100%;margin:0 0 8px;position:static;width:100%}#fourchanx-settings.settings-compact-content .section-advanced #archive-table{border-collapse:separate;border-spacing:0;display:block;max-width:100%;width:100%}#fourchanx-settings.settings-compact-content .section-advanced #archive-table thead{display:none}#fourchanx-settings.settings-compact-content .section-advanced #archive-table tbody{display:block;max-width:100%}#fourchanx-settings.settings-compact-content .section-advanced #archive-table tbody tr:not([hidden]){box-sizing:border-box;display:grid;gap:6px;max-width:100%;padding:8px;border:1px solid rgba(128,128,128,.28);border-radius:6px;background:rgba(128,128,128,.06)}#fourchanx-settings.settings-compact-content .section-advanced .archive-cell{box-sizing:border-box;display:grid;grid-template-columns:minmax(0,9.5em) minmax(0,1fr);gap:8px;align-items:center;min-width:0;padding:2px 0;text-align:left}#fourchanx-settings.settings-compact-content .section-advanced .archive-cell::before{content:attr(data-label);font-weight:700;line-height:1.2}#fourchanx-settings.settings-compact-content .section-advanced .archive-cell select{box-sizing:border-box;min-width:0;max-width:100%;width:100%}.section-advanced .note{font-size:0.8em;font-style:italic;margin-left:10px}.section-advanced .note code{font-style:normal;font-size:11px}.favicon-preview>img{vertical-align:middle}.favicon-preview>img:nth-of-type(3n+1){margin-left:4px}.section-keybinds .field{font-family:monospace}.keybind-click-marker{margin-right:0.25em;font-weight:700;opacity:0.8}#xt-settings-overlay details{border:1px solid rgba(128,128,128,.3);border:1px solid color-mix(in srgb,currentColor 22%,transparent);border-radius:4px;padding:0 0.75em;margin:4px 2px}#xt-settings-overlay details[open]{padding-bottom:0.85em}#xt-settings-overlay details{transition:background-color 120ms ease}#xt-settings-overlay details:hover,#xt-settings-overlay details:focus-within{background:color-mix(in srgb,currentColor 6%,transparent)}#xt-settings-overlay summary{display:flex;align-items:center;justify-content:space-between;gap:0.5em;font-weight:700;color:inherit;cursor:pointer;padding:6px 8px;margin:0 -0.75em;list-style:none;user-select:none}#xt-settings-overlay details[open]>summary{margin-bottom:6px;border-bottom:1px solid rgba(128,128,128,.2);border-bottom:1px solid color-mix(in srgb,currentColor 14%,transparent)}#xt-settings-overlay .section-container section>details>summary,#xt-settings-overlay .settings-section-content>details>summary{font-size:1.08em}#xt-settings-overlay summary::-webkit-details-marker,#xt-settings-overlay summary::marker{display:none;content:''}#xt-settings-overlay summary::after{content:"▾";font-size:0.85em;line-height:1;opacity:.55;transition:transform 120ms ease,opacity 120ms ease}#xt-settings-overlay summary:hover{background:rgba(128,128,128,.03);background:color-mix(in srgb,currentColor 3%,transparent)}#xt-settings-overlay summary:hover::after{opacity:1}#xt-settings-overlay details:not([open])>summary::after{transform:rotate(-90deg)}#xt-settings-overlay textarea:not(.custom-css-textarea){font-family:monospace;width:100%;resize:vertical}#xt-settings-overlay textarea:not(.custom-css-textarea):not(:focus){cursor:default;overflow:hidden}#xt-settings-overlay .settings-textarea-shell{position:relative;width:100%}#xt-settings-overlay .settings-textarea-shell>textarea{width:100%}#xt-settings-overlay textarea.has-custom-resizer{resize:none;display:block}#xt-settings-overlay .settings-textarea-resizer{height:12px;margin-top:-1px;margin-bottom:6px;display:flex;align-items:center;justify-content:center;cursor:ns-resize;border-radius:0 0 3px 3px;background:var(--xt-border,rgba(128,128,128,.18));user-select:none;touch-action:none}#xt-settings-overlay .settings-textarea-resizer::before{content:'';width:30px;height:3px;border-radius:2px;background:currentColor;opacity:.4}#xt-settings-overlay .settings-textarea-resizer:hover::before,#xt-settings-overlay .settings-textarea-resizer.dragging::before{opacity:.8}#xt-settings-overlay .settings-textarea-resizer.dragging{background:var(--xt-border,rgba(128,128,128,.35))}#xt-settings-overlay code{color:#000;background-color:#FFF;padding:0 2px}.sound-row{display:flex;align-items:center;gap:0.6em;padding:6px 2px}.sound-row--stack{flex-direction:column;align-items:flex-start;gap:0.3em}.sound-row__hint{opacity:0.55;font-size:0.85em}.sound-section{margin:12px 0;border:1px solid rgba(0,0,0,.15);border-radius:6px;overflow:hidden;background:rgba(0,0,0,.05)}.sound-section__head{display:flex;align-items:center;justify-content:space-between;gap:0.5em;padding:8px 12px;border-bottom:1px solid rgba(0,0,0,.12);background:rgba(0,0,0,.07)}@media (prefers-color-scheme:dark){.sound-section{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.04)}.sound-section__head{border-bottom-color:rgba(255,255,255,.12);background:rgba(255,255,255,.06)}}.sound-section__title{margin:0;font-size:0.85em;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;opacity:0.75}.sound-section__title-wrap{display:flex;align-items:baseline;gap:0.6em}.sound-section__hint{font-size:0.78em;opacity:0.55;font-style:italic}.sound-section__actions{display:flex;gap:0.4em}.sound-list{display:flex;flex-direction:column}.sound-list__row{display:flex;align-items:center;gap:0.5em;padding:7px 12px;border-bottom:1px solid rgba(0,0,0,.08);transition:background-color 100ms ease}.sound-list__row:last-child{border-bottom:none}.sound-list__row:hover{background:rgba(0,0,0,.06)}.sound-list__row--builtin{opacity:0.92}.sound-list__row--default{background:rgba(0,0,0,.05)}@media (prefers-color-scheme:dark){.sound-list__row{border-bottom-color:rgba(255,255,255,.08)}.sound-list__row:hover{background:rgba(255,255,255,.06)}.sound-list__row--default{background:rgba(255,255,255,.05)}}.sound-list__row--default .sound-list__name-label,.sound-list__row--default .sound-list__name-input{font-weight:600}.sound-list__default{flex:0 0 auto;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0 2px}.sound-list__default input[type="radio"]{margin:0;cursor:pointer}.sound-list__empty{padding:14px 12px;text-align:center;font-style:italic;opacity:0.55}.sound-list__name{flex:1 1 auto;display:flex;align-items:center;gap:0.5em;min-width:0;overflow:hidden}.sound-list__name-label{font-weight:500}.sound-list__name-input{flex:1 1 auto;width:100%;min-width:0}.sound-list__badge{font-size:0.7em;letter-spacing:0.04em;text-transform:uppercase;padding:1px 6px;border-radius:999px;background:rgba(128,128,128,.2);opacity:0.75}.sound-list__board{font-family:monospace;font-size:0.95em}.sound-list__sep{opacity:0.45}.sound-list__sound--missing{opacity:0.55;font-style:italic}.sound-list__actions{display:flex;gap:0.25em;flex-shrink:0}.sound-btn{cursor:pointer;border:1px solid rgba(128,128,128,.3);background:rgba(128,128,128,.08);color:inherit;padding:4px 10px;border-radius:4px;font:inherit;font-size:0.9em;transition:background-color 100ms ease,border-color 100ms ease}.sound-btn:hover{background:rgba(128,128,128,.18);border-color:rgba(128,128,128,.45)}.sound-btn--primary{font-weight:600}.sound-btn--icon{padding:2px 8px;min-width:26px;line-height:1.2}.sound-btn--danger:hover{background:color-mix(in srgb,#d33 22%,transparent);border-color:color-mix(in srgb,#d33 50%,transparent)}.sound-compose{display:flex;gap:0.5em;padding:8px 12px;border-top:1px solid rgba(0,0,0,.12);background:rgba(0,0,0,.06)}@media (prefers-color-scheme:dark){.sound-compose{border-top-color:rgba(255,255,255,.12);background:rgba(255,255,255,.06)}}.sound-compose__board{flex:0 0 12em}.sound-compose__sound{flex:1 1 auto;min-width:0}.sound-advanced{margin-top:12px}.sound-status{padding:6px 12px;font-size:0.85em;border-top:1px solid rgba(128,128,128,.15);color:#c0392b}.sound-status[data-kind="ok"]{color:inherit;opacity:0.7}#xt-settings-overlay th{text-align:center;font-weight:bold}#xt-settings-overlay p{margin:1em 0px}#xt-settings-overlay table{margin:auto}:root.index-loading .navLinks:not(.json-index),:root.index-loading .board:not(.json-index),:root.index-loading .pagelist:not(.json-index),:root.infinite-mode .pagelist,:root.all-pages-mode .pagelist,:root.catalog-mode .pagelist,:root:not(.catalog-mode) .indexlink,:root.catalog-mode .cataloglink,:root:not(.catalog-mode) #hidden-label,:root:not(.catalog-mode) #index-size-row,:root:not(.catalog-mode) #index-columns-row{display:none}#index-controls-toggle,#index-options-backdrop,.index-options-header,.index-option-label{display:none}.index-option-row{display:inline-block;vertical-align:middle}#index-search-toggle{display:none}#index-search-row{display:contents}#index-search-help-wrap{position:relative;display:inline-block;vertical-align:middle}#index-search-help{color:gray;display:inline-flex;align-items:center;justify-content:center;width:1.25em;height:1.25em;margin:0 .25em 0 .1em}#index-search-help:hover,#index-search-help[aria-expanded="true"]{color:inherit}#index-search-help-popover{position:absolute;z-index:4;top:1.6em;left:0;box-sizing:border-box;width:320px;max-height:70vh;overflow-y:auto;overscroll-behavior:contain;padding:6px 8px;text-align:left;line-height:1.4;white-space:normal}#index-search-help-popover strong,#index-search-help-popover span{display:block}#index-search-help-popover strong{margin-bottom:4px}#index-search-help-popover strong.section{margin-top:8px;opacity:.85;font-size:11px;text-transform:uppercase;letter-spacing:.04em}#index-search-help-popover span.note{margin-top:2px;opacity:.8;font-size:11px}#index-search-help-popover code{font-size:12px;background:color-mix(in srgb,var(--xt-header-link,var(--xt-fxt-fg,gray)) 25%,transparent);color:inherit;padding:1px 5px;border-radius:3px;box-decoration-break:clone;-webkit-box-decoration-break:clone}#index-search{padding-right:1.5em;width:100px;transition:color .25s,border-color .25s,width .25s}#index-search:focus,#index-search[data-searching]{width:200px}#index-search-clear{color:gray;position:relative;left:-1.5em}#index-search::-webkit-search-cancel-button{display:none}#index-search:not([data-searching]) + #index-search-clear{display:none}#index-options{float:right}#lastlong-options{display:inline-block;vertical-align:middle;height:28px;margin:-14px 0}#lastlong-options>input{padding:0;border:0 !important;text-align:center;background:transparent;display:block;font-size:12px;height:12px;width:30px;margin:1px 0}.summary{text-decoration:none}:root.catalog-mode .board{text-align:center}.catalog-thread{display:inline-block;box-sizing:border-box;border:1px solid transparent;word-wrap:break-word;vertical-align:top;position:relative}.catalog-group-header{display:block;margin:8px 2px 4px;text-align:left;font-weight:bold}.catalog-group-match{display:block;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:.85}.catalog-thread.catalog-thread{margin:2px}.catalog-small>.catalog-thread{width:165px;height:320px}.catalog-large>.catalog-thread{width:270px;height:410px}.board.catalog-columns{display:grid;grid-template-columns:repeat(var(--catalog-columns,4),1fr);clear:both}.catalog-columns>.catalog-thread.catalog-thread{width:auto;height:auto;aspect-ratio:var(--catalog-tile-aspect,270 / 410)}.catalog-columns>.catalog-group-header{grid-column:1 / -1}.catalog-columns .catalog-thumb{max-width:100%}:root.catalog-hover-expand .catalog-thread:hover{z-index:1}.catalog-container{position:absolute;top:-4px;left:0;right:0;bottom:0}.catalog-container:not(:hover),:root:not(.catalog-hover-expand) .catalog-container{overflow:hidden}.catalog-post{position:absolute;top:4px;left:0;right:0;border:1px solid transparent;padding-top:20px}:root:not(.catalog-hover-expand) .catalog-post{left:0 !important;right:0 !important}.catalog-post.catalog-post{margin:-21px -1px -1px;overflow:visible}.catalog-thread.noFile>*>.catalog-post{margin-top:-7px;padding-top:6px}:root.catalog-hover-expand .catalog-container:hover>.catalog-post{margin-left:-61px;margin-right:-61px}:root.catalog-hover-expand .catalog-container:hover>*>:not(.catalog-replies){padding-left:2px;padding-right:2px}.catalog-link{display:block;position:relative}.catalog-thumb{border-radius:2px;box-shadow:0 0 5px rgba(0,0,0,.25);vertical-align:top}.catalog-thumb.spoiler-file{width:100px;height:100px}.catalog-thumb.deleted-file{width:127px;height:13px;padding:20px 11px}.catalog-thumb.no-file{width:77px;height:13px;padding:20px 36px}.catalog-icons>img,.catalog-stats>.menu-button{width:1em;height:1em;margin:0;vertical-align:text-top;padding-left:2px}.catalog-stats>.menu-button>.icon{height:10px}.catalog-stats{font-size:10px;font-weight:700;padding-top:2px}.catalog-stats>[title]{cursor:help}.catalog-post>.postMessage{margin:0;padding-bottom:.3em}:is(.catalog-container:not(:hover),:root:not(.catalog-hover-expand) .catalog-container)>*>:is(.file,.catalog-replies),:is(.catalog-container:not(:hover),:root:not(.catalog-hover-expand) .catalog-container)>*>.postInfo>:not(.subject),:is(.catalog-container:not(:hover),:root:not(.catalog-hover-expand) .catalog-container) :is(.extra-linebreak,.abbr),.catalog-thread>.catalog-container>:not(.catalog-post),.preview-summary,.catalog-post>.file>:not(.fileText),.catalog-post>*>.fileText>:not(:first-child),.catalog-post>.postInfo>:not(.subject):not(.nameBlock):not(.dateTime),.catalog-post>.postInfo>.nameBlock>.contact-links,.catalog-post>*>*>:is(.posteruid,.postJumper),:root.bottom-backlinks .catalog-post>.container,.post:not(.catalog-post)>:is(.catalog-link,.catalog-stats,.catalog-replies){display:none}.catalog-post>.file{position:absolute;left:0;right:0;top:0;min-height:20px;background-color:inherit}.catalog-post>*>.fileText{position:relative;padding:2px;background-color:inherit}.catalog-small .catalog-post>* .fileText{font-size:10px}.catalog-post>*>.fileText:not(:hover){white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.catalog-post>*>.fileText:hover{z-index:1}.catalog-post>.postInfo.postInfo{width:auto}.catalog-post>*>.subject{display:block}.catalog-post>*>.dateTime{display:inline-block;font-style:italic}:root.catalog-hover-expand .catalog-container:hover>*>*>.nameBlock,:root.catalog-hover-expand .catalog-container:hover>*>*>.dateTime,:root.catalog-hover-expand .catalog-container:hover>*>.postMessage:not(:empty){padding-top:.3em}.catalog-post .extra-linebreak{content:'';display:block;margin-top:.3em}.catalog-reply{text-align:left;white-space:nowrap;border-top:1px solid transparent;display:flex;flex-direction:row;align-items:stretch}.catalog-reply>*{padding:3px;overflow:hidden;flex:none}.catalog-reply>span{font-style:italic;font-weight:bold}.catalog-reply-excerpt{flex:1 1 auto}.catalog-post .prettyprinted{max-width:100%;box-sizing:border-box}.catalog-post .MathJax_Display{text-align:center !important}.catalog-container:not(:hover) .exif,:root:not(.catalog-hover-expand) .catalog-container .exif{display:none !important}.catalog-post>*>.exif{border-collapse:collapse}:root.catalog-hover-expand .catalog-container:hover .exif[style*="display:block;"]{display:inline-block !important}.catalog-post>*>.exif,.catalog-post>*>.exif>tbody{background-color:inherit}.catalog-post>*>.exif,.catalog-post>*>.exif td{min-width:0}.catalog-post>*>.exif td{padding-top:1px}:root.hats-enabled .catalog-thread::after{content:'';pointer-events:none;position:absolute;background-size:contain}:root.hats-enabled .catalog-small>.catalog-thread::after{left:-8px;top:-59px;width:96px;height:96px}:root.hats-enabled:not(.werkTyme) .catalog-small>.catalog-thread:not(.noFile)::after{left:calc(67px - .3px * var(--tn-w))}:root.hats-enabled .catalog-large>.catalog-thread::after{left:-15px;top:-98px;width:160px;height:160px}:root.hats-enabled:not(.werkTyme) .catalog-large>.catalog-thread:not(.noFile)::after{left:calc(110px - .5px * var(--tn-w))}textarea.copy-text-element{height:0;width:0;position:absolute;top:-10000px}:root.hide-announcement __xt-site-psa{display:none}.hide-announcement-button{opacity:0.4;float:left}.unread-line{margin:0}.unread-line + br{display:none}.unread-mark-read{float:right;clear:both;width:100%;text-align:right}:not(.unread-thread)>.unread-mark-read{display:none}#updater{background:none;border:none;box-shadow:none}#updater>.move{position:absolute;top:-5px;bottom:-5px;left:-5px;right:-5px;z-index:-1}#updater>div:last-child{text-align:center}#updater input[type="number"]{width:4em}:root.float #updater{padding:0px 3px}:root:not(.float).shortcut-icons #updater{display:inline-block;min-width:12pt;text-align:right}.new{color:limegreen}#update-status:not(.empty) + #update-timer:not(.empty):not(.loading){margin-left:5px}#update-timer{cursor:pointer}#thread-watcher{position:absolute}#thread-watcher{padding-bottom:3px;padding-left:3px;white-space:nowrap;min-width:146px;--watcher-thumb-size:40px;--watcher-max-height:210px;--watcher-max-width:250px}#watched-threads{overflow-x:hidden;overflow-y:auto}#watched-threads::-webkit-scrollbar{width:4px}#watched-threads::-webkit-scrollbar-button{display:none;width:0;height:0}#watched-threads::-webkit-scrollbar-track{background:transparent}#watched-threads::-webkit-scrollbar-thumb{background-color:var(--xt-border,rgba(128,128,128,.5));border-radius:3px}#watched-threads::-webkit-scrollbar-thumb:hover{background-color:var(--xt-border,rgba(128,128,128,.8))}@supports not selector(::-webkit-scrollbar){#watched-threads{scrollbar-width:thin;scrollbar-color:var(--xt-border,rgba(128,128,128,.5)) transparent}}#thread-watcher>.watcher-scroll-more{position:absolute;left:3px;right:3px;bottom:3px;height:15px;display:flex;align-items:flex-end;justify-content:center;cursor:pointer;color:inherit;background:linear-gradient(to bottom,transparent,var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas))) 80%);z-index:2}#thread-watcher>.watcher-scroll-more[hidden]{display:none}#thread-watcher>.watcher-scroll-more.has-you{background:linear-gradient(to bottom,transparent,color-mix(in srgb,#F00 35%,var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)))) 80%)}#thread-watcher>.watcher-scroll-more>.icon{width:11px;height:11px;opacity:.6;animation:watcher-scroll-more-bounce 1.4s ease-in-out infinite}#thread-watcher>.watcher-scroll-more:hover>.icon{opacity:1}#thread-watcher>.watcher-scroll-more.has-you>.icon{color:#F00;opacity:1}@keyframes watcher-scroll-more-bounce{0%,100%{transform:translateY(0)}50%{transform:translateY(2px)}}@media (prefers-reduced-motion:reduce){#thread-watcher>.watcher-scroll-more>.icon{animation:none}}:root.fixed-watcher #thread-watcher{position:fixed}:root.fixed-watcher #watched-threads{max-height:min(var(--watcher-max-height),calc(100vh - 75px))}:root:not(.fixed-watcher) #watched-threads:not(:hover){max-height:var(--watcher-max-height);overflow-y:hidden}#thread-watcher>.move{padding-top:3px;display:flex;align-items:center;gap:3px}#thread-watcher>.move>a{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex:0 0 18px;padding:0;line-height:1;vertical-align:middle}#thread-watcher>.move>a>.icon{display:block}#thread-watcher>.move>a.mark-read.watcher-undo-pending{position:relative;opacity:1;pointer-events:auto}#thread-watcher>.move>a.mark-read.watcher-undo-pending>.icon{font-size:.82em}.watcher-undo-ring{position:absolute;inset:1px;pointer-events:none}.watcher-undo-ring>svg{width:100%;height:100%;transform:rotate(-90deg)}.watcher-undo-ring circle{--circumference:47.124;fill:none;stroke:var(--xt-undo-ring-color,var(--xt-border-highlight,#d83030));stroke-width:1.5;stroke-linecap:round;opacity:.8;stroke-dasharray:var(--circumference);stroke-dashoffset:0;animation:watcher-undo-ring-countdown var(--undo-duration,5000ms) linear forwards}@keyframes watcher-undo-ring-countdown{from{stroke-dashoffset:0}to{stroke-dashoffset:var(--circumference)}}#watched-threads>div{padding-left:3px;padding-right:3px;display:flex;align-items:center;gap:4px;cursor:move}#watched-threads>div.drag{opacity:.6}#watched-threads>div.over{outline:1px dashed currentColor}#watched-threads .watcher-link{max-width:var(--watcher-max-width);display:inline-flex;flex-direction:row;align-items:center;min-width:0}#watched-threads .watcher-thumb{width:40px;height:40px;width:var(--watcher-thumb-size);height:var(--watcher-thumb-size);flex:0 0 auto;object-fit:cover;margin-right:4px}#watched-threads .watcher-thumb-missing{display:inline-block;background:rgba(128,128,128,0.28)}#watched-threads .watcher-page,#watched-threads .watcher-unread{flex:0 0 auto;margin-right:2px}#watched-threads .watcher-title{overflow:hidden;text-overflow:ellipsis;flex:0 1 auto}#watched-threads .watcher-title:not(:first-child){margin-left:2px}.replies-quoting-you>a,#watcher-link.replies-quoting-you,.last-page>a>.watcher-page{color:#F00}#thread-watcher a{text-decoration:none}#thread-watcher .move>.close{}#thread-watcher>.move>.attach{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex:0 0 18px;padding:0;margin-left:auto;margin-right:1px;opacity:0.7}#thread-watcher>.move>.attach:hover{opacity:1}#thread-watcher>.move>.attach.attached{opacity:1}#thread-watcher>.move>.attach.attach-controls-hidden{visibility:hidden;pointer-events:none}#thread-watcher.watcher-attached{}#thread-watcher .mark-read.disabled{opacity:.5;pointer-events:none}#thread-watcher .watcher-mark-read{margin-left:auto;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex:0 0 18px;opacity:.85}#thread-watcher .watcher-mark-read.disabled{opacity:.4;pointer-events:none}.watch-thread-link{width:18px;height:18px;background:none;border:none;padding:2px;opacity:0.2;filter:none;cursor:pointer;color:var(--xt-watcher,#000)}.watch-thread-link.watched{opacity:1}:root.oneechan .watch-thread-link>svg{display:none}#thread-stats{background:none;border:none;box-shadow:none}#shortcut-stats{white-space:nowrap;flex-shrink:0}:root.float #thread-stats>.move>:not(#page-count){pointer-events:none}:root.float #thread-stats{padding:0px 3px}#page-count{cursor:pointer}.hashlink::before{content:' ';visibility:hidden}.inline + .hashlink{display:none !important}:root.resurrect-quotes .deadlink{text-decoration:none !important}.catalog-post .qmark-ct{display:none}.backlink.deadlink:not(.forwardlink),.quotelink.deadlink:not(.forwardlink){text-decoration:underline !important}:root:not(.catalog-mode) .inlined{opacity:.5}#qp input,.forwarded{display:none}.quotelink.forwardlink,.backlink.forwardlink{text-decoration:underline dashed}.filtered{text-decoration:underline line-through}:root.hide-backlinks .backlink.filtered,:root.hide-backlinks .backlink.filtered + .hashlink.filtered{display:none}.postNum + .container::before{content:" "}:root.bottom-backlinks .container{display:block;clear:both;margin:0 4px}:root.bottom-backlinks .backlink{font-size:90%}.inline{border:1px solid;margin:2px 0;display:flow-root;width:fit-content;max-width:100%}.container~.inline{margin-left:20px}:root.catalog-mode .inline{display:none}.inline .post{border:0 !important;background-color:transparent !important;margin:0 !important;padding:1px 2px !important}.inline .postMessage{overflow-wrap:break-word;word-break:break-all}.inline::after{content:'';clear:both;display:table}#qp>.opContainer::after{content:'';clear:both;display:table}#qp .post{border:none;margin:0;padding:2px 2px 5px}#qp img{max-height:80vh;max-width:50vw}#qp .preview-summary{display:block}.threadContainer{margin-left:20px;border-left:1px solid rgba(128,128,128,.3)}.threadOP{clear:both}.expanded-image>.post>.file>.fileThumb{display:flex;flex-direction:column}.fileText-original,.fnswitch:hover>.fntrunc,.fnswitch:not(:hover)>.fnfull,.expanded-image>.post>.file>.fileThumb>video[data-md5],.expanded-image>.post>.file>.fileThumb>img[data-md5]{display:none}.full-image[data-file-i-d]{display:none;cursor:pointer}.expanded-image>.post>.file>.fileThumb>.full-image{display:inline}.expanded-image>.post>.file>.fileThumb>audio{height:30px;width:100%;min-width:300px}.expanded-image{clear:left}.expanded-image .full-image,.expanded-image>.post>.file>.fileThumb{position:relative;z-index:1}.expanding{opacity:.5}:root.fit-height .full-image{max-height:100vh}:root.fit-height.fixed .full-image{max-height:calc(100vh - 35px)}:root.fit-width .full-image{max-width:100%}:root.ua-gecko.fit-width .full-image{width:100%}.fileThumb>.warning{clear:both}.fileThumb.has-video-duration{position:relative}.video-duration-badge{position:absolute;bottom:4px;right:4px;background:rgba(0,0,0,0.8);color:#fff;font-size:11px;font-weight:600;line-height:1;padding:2px 4px;border-radius:3px;pointer-events:none;font-variant-numeric:tabular-nums;z-index:1}.expanded-image .video-duration-badge{display:none}.setting-unavailable{opacity:0.5}#ihover{pointer-events:none;max-height:calc(100vh - 25px);max-width:100vw}#tw-ihover{pointer-events:none;max-height:calc(100vh - 16px);max-width:40vw}.webm-title>a::before{content:"webm";text-decoration:underline}.webm-title.loading>a::after{content:"..."}.webm-title.error>a:hover::before,.webm-title.error>a:focus::before{content:"error";text-decoration:none}.webm-title>span{cursor:text}.webm-title.not-found>span::before{content:"no title"}.webm-title:not(:hover):not(:focus)>span,.webm-title:hover>span + a,.webm-title:focus>span + a{display:none}input[name="Default Volume"],input[name="beepVolume"]{width:10em;height:1ex;vertical-align:middle;margin:0px}:root.fappeTyme __xt-site-replyOriginal.noFile,:root.fappeTyme __xt-site-replyOriginal.noFile + br{display:none}:root.werkTyme __xt-site-thumbLink,:root.werkTyme __xt-site-file-thumb,:root.werkTyme .catalog-thumb:not(.deleted-file):not(.no-file),:root:not(.werkTyme) .werkTyme-filename{display:none}.werkTyme-filename{font-weight:bold;font-size:110%}:root.werkTyme .catalog-link{box-shadow:0 0 5px rgba(0,0,0,.25);padding:8px;text-align:center}:root.werkTyme .catalog-thumb{box-shadow:none;padding:0;vertical-align:middle}.indicator{background:rgba(255,0,0,0.8);font-weight:bold;display:inline-block;min-width:9px;padding:0px 2px;margin:0 1px;text-align:center;color:white;border-radius:2px;cursor:pointer}:root:not(.fappeTyme) #shortcut-fappe,:root:not(.werkTyme) #shortcut-werk{display:none !important}#navlinks{top:25px;right:10px}:root.catalog-mode #navlinks{display:none}:root.highlight-own .yourPost__xt-site-highlightable-op::after,:root.highlight-you .quotesYou__xt-site-highlightable-op::after,.filter-highlight__xt-site-highlightable-op::after{content:"";display:block;clear:both}:root.reveal-spoilers __xt-site-spoiler,:root.reveal-spoilers __xt-site-spoiler>a{color:white !important}:root.reveal-spoilers .removed-spoiler::before{content:"[spoiler]"}:root.reveal-spoilers .removed-spoiler::after{content:"[/spoiler]"}.hide-thread-button,.hide-reply-button{float:left;margin-right:4px;padding:2px}__xt-site-infoRoot a.hide-reply-button{margin-right:6px;padding:0}.replacedSideArrows{float:left}.hide-thread-button:not(:hover),.hide-reply-button:not(:hover){opacity:0.4}.threadContainer .hide-reply-button{margin-left:2px !important;position:relative;left:1px}.hide-thread-button{margin-top:-1px;width:11px}.stub~:not(.threadDivider){display:none !important}.stub input{display:inline-block}.stub .stub-icon,.stub-subject{margin-right:1ch}.stub-replies{margin-left:1ch}.stub-reasons::before{content:' ('}.stub-reasons::after{content:')'}.stub-reason:not(:last-of-type)::after{content:' & '}__xt-site-thread[hidden] + hr{display:none}:root.reply-hide __xt-site-sideArrows{display:none}:root.sw-yotsuba.thread-hide .party-hat{left:19px}:root.anonymize __xt-site-info-name,:root.sw-yotsuba.anonymize .post-author:not([class*=capcode]){font-size:0}:root.anonymize __xt-site-info-tripcode,:root.sw-yotsuba.anonymize .n-pu{display:none}:root.anonymize __xt-site-info-name::before,:root.sw-yotsuba.anonymize .post-author:not([class*=capcode])::before{content:"Anonymous";font-size:10pt}:root.sw-yotsuba.anonymize .flashListing .name::before,:root.sw-yotsuba.anonymize .post-last>.post-author:not([class*=capcode])::before{font-size:9pt}#qr.autohide:not(.focus):not(:hover):not(:active)>form,:root.thread-view #qr:not(.show-new-thread-option) select[data-name="thread"],#file-n-submit:not(.has-file) #qr-filerm{display:none}:root.hide-original-post-form:not(.show-original-post-form) #postForm{display:none !important}:root.hide-original-post-form.show-original-post-form #postForm{display:table !important}#qr select,#qr-filename-container>a,.remove{cursor:pointer}#qr{position:fixed;padding:1px;border:1px solid transparent;min-width:300px;border-radius:3px 3px 0 0}#qr>form{max-height:calc(100vh - 75px);overflow:hidden auto}.qr-link-container{text-align:center;margin:16px 0}.qr-link-container-bottom{position:absolute;margin-left:50%;transform:translateX(-50%)}.qr-link{border-radius:3px;padding:6px 10px 5px;font-weight:bold;vertical-align:middle;border-style:solid;border-width:1px;font-size:10pt}.qr-link-container + #togglePostFormLink{font-size:10pt;font-weight:normal;margin:-8px 0 3.5px}.persona{width:100%;display:flex;flex-direction:row}.persona .field{flex:1;width:0}.persona .qr-persona-picker{flex:0 0 auto;width:1.5em;min-width:1.5em;margin-left:-1px;padding:0;cursor:pointer;box-sizing:border-box}select.flagSelector{position:absolute;width:1px;height:1px;margin:0;padding:0;border:0;clip:rect(0 0 0 0);clip-path:inset(50%);overflow:hidden;white-space:nowrap}.flagSelector-picker,.flagSelector-menu{--xt-flag-selector-hover-bg:var(--xt-entry-focus-bg,rgba(127,127,127,0.25))}.flagSelector-picker{position:relative}.flagSelector-toggle{align-items:center;-webkit-appearance:none;appearance:none;background-color:var(--xt-qr-sampled-bg,transparent) !important;border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-color:var(--xt-qr-sampled-border,color-mix(in srgb,currentColor 25%,transparent)) !important;cursor:pointer;display:flex;gap:6px;font:inherit;color:var(--xt-qr-sampled-fg,inherit) !important;min-height:22px;padding:2px 20px 2px 6px;position:relative;text-align:left;width:100%}.flagSelector-toggle::after{content:'';position:absolute;right:6px;top:50%;width:0;height:0;border:4px solid transparent;border-top-color:currentColor;transform:translateY(-25%);opacity:0.6;pointer-events:none}.flagSelector-picker.open .flagSelector-toggle::after{transform:translateY(-75%) rotate(180deg)}.flagSelector-toggle[disabled]{cursor:default;opacity:0.6}.flagSelector-menu{background-color:var(--xt-qr-sampled-bg,#fff) !important;border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-color:var(--xt-qr-sampled-border,color-mix(in srgb,currentColor 25%,transparent)) !important;border-radius:2px;color:var(--xt-qr-sampled-fg,inherit) !important;box-shadow:0 2px 4px color-mix(in srgb,currentColor 25%,transparent);box-sizing:border-box;overflow-y:auto;position:fixed;z-index:10000}.flagSelector-option{align-items:center;background-color:transparent;border:0;color:inherit;cursor:pointer;display:flex;font:inherit;gap:6px;padding:3px 6px;text-align:left;width:100%}.flagSelector-option,.flagSelector-option:hover,.flagSelector-option:focus,.flagSelector-option:active{color:inherit !important;-webkit-text-fill-color:inherit !important}.flagSelector-option:hover,.flagSelector-option:focus{background-color:var(--xt-flag-selector-hover-bg);outline:none}.flagSelector-option.selected{font-weight:600}.flagSelector-icon{align-self:center;display:inline-block;flex:none;height:11px;margin:0;position:static;top:auto;width:16px}.flagSelector-icon-empty{background-image:none}.flagSelector-label{line-height:1.25}#qr.forced-anon :is(input[data-name="name"]:not(.force-show),input[data-name="sub"]:not(.force-show)),#qr.reply-to-thread input[data-name="sub"]:not(.force-show),body:not(.board_f) #qr select[name="filetag"],#qr.reply-to-thread select[name="filetag"],#qr:not(.has-sjis) #sjis-toggle,#qr:not(.has-math) #tex-preview-button,#qr.tex-preview .textarea>:not(#tex-preview),#qr:not(.tex-preview) #tex-preview,#qr:not(.has-com-preview) #qr-com-preview{display:none}#qr-com-preview{padding:2px 4px;overflow:auto;white-space:pre-wrap;word-wrap:break-word;background:var(--reply-background-color,transparent);color:inherit;border:1px solid rgba(0,0,0,.2);font-size:inherit;box-sizing:border-box}#qr-com-preview:empty::before{content:"Preview";opacity:.4}#qr-com-preview pre.prettyprint{white-space:pre-wrap;margin:0}#qr-com-preview .quote{color:var(--xt-qr-com-preview-quote-color,var(--xt-quote-color,#789922))}#qr.has-com-preview.com-preview-below .textarea{flex-direction:column}#qr.has-com-preview.com-preview-right .textarea{flex-direction:row}#qr.has-com-preview.com-preview-left .textarea{flex-direction:row-reverse}#qr.com-preview-below #qr-com-preview{margin-top:2px;min-height:6em;max-height:14.8em;width:100%}#qr.com-preview-right #qr-com-preview,#qr.com-preview-left #qr-com-preview{flex:1 1 0;min-width:200px;align-self:stretch}#qr.com-preview-right #qr-com-preview{margin-left:2px}#qr.com-preview-left #qr-com-preview{margin-right:2px}.postContainer.qr-preview-post{opacity:0.92;max-width:100%}.postContainer.qr-preview-post>.reply{border:1px dashed rgba(120,120,120,0.55);border-left:3px solid var(--xt-qr-preview-accent,#ff8c00);box-shadow:inset 0 0 0 1px rgba(255,140,0,0.30),0 0 7px rgba(255,140,0,0.30);background-image:repeating-linear-gradient( 135deg,transparent,transparent 3px,rgba(0,0,0,0.025) 3px,rgba(0,0,0,0.025) 6px );box-sizing:border-box}.postContainer.qr-preview-post .postInfo .preview-no .preview-label{font-style:italic;opacity:0.7;margin-left:2px}.postContainer.qr-preview-post .qr-preview-file-note{font-size:90%;opacity:0.85;margin:2px 0}.postContainer.qr-preview-post .postMessage:empty::before{content:"(empty post body)";opacity:0.5;font-style:italic}.qr-preview-float{contain:layout paint;min-width:180px;max-width:none;z-index:21;cursor:move;will-change:left,top}.qr-preview-float .qr-preview-post{margin:0;opacity:1}.qr-preview-float .qr-preview-post>.reply{box-shadow:0 2px 8px rgba(0,0,0,0.18);display:inline-block;width:auto}.qr-preview-post .menu-button{display:none !important}.qr-preview-float a{cursor:pointer}.qr-preview-inline-toggle{cursor:pointer;color:var(--xt-link-text-color,inherit)}.qr-preview-inline-toggle.is-dockable{text-decoration:underline dotted;text-underline-offset:2px}.qr-preview-inline-toggle.is-dockable:hover{text-decoration:underline}.qr-preview-post .file,.qr-preview-float .file{margin-top:2px;margin-bottom:4px;max-width:100%}.qr-preview-post .fileText,.qr-preview-float .fileText{margin-bottom:1px;word-break:break-all;overflow-wrap:break-word}.qr-preview-post .qr-preview-file-thumb,.qr-preview-float .qr-preview-file-thumb{min-width:125px}.qr-preview-post .fileThumb img,.qr-preview-post .fileThumb video,.qr-preview-float .fileThumb img,.qr-preview-float .fileThumb video{max-width:125px;max-height:125px;display:block;border:1px solid rgba(0,0,0,.2);background:#fff}.postContainer.qr-preview-post .postMessage,.qr-preview-float .postMessage{white-space:pre-wrap;overflow-wrap:normal;word-break:normal;word-wrap:normal}.postContainer.qr-preview-post::after,.qr-preview-float .qr-preview-post::after{content:'';clear:both;display:table}#qr.com-preview-thread #qr-com-preview,#qr.com-preview-float #qr-com-preview,#qr:not(.has-com-preview) #qr-com-preview{display:none}#sjis-toggle,#qr.sjis-preview textarea.field{font-family:"IPAMonaPGothic","Mona","MS PGothic",monospace;font-size:16px;line-height:17px}#tex-preview-button{font-size:10px}#tex-preview{white-space:pre-line}.textarea{position:relative;display:flex}#qr textarea.field{height:14.8em;min-height:9em;min-width:300px;resize:both}#qr.has-captcha textarea.field{height:9em}#char-count{color:#000;background:hsla(0,0%,100%,.5);font-size:8pt;position:absolute;bottom:1px;right:1px;pointer-events:none}#char-count.warning{color:red}input.field.tripped:not(:hover):not(:focus){color:transparent !important;text-shadow:none !important}#qr>.move{display:flex;align-items:center}#qr>.move label{display:inline-flex;align-items:center;padding:0}#qr-preview-toggle{align-items:center;appearance:none;background:transparent;border:0;color:inherit;cursor:pointer;display:inline-flex;font:inherit;height:18px;justify-content:center;line-height:inherit;margin:0 1px 0 6px;opacity:.45;padding:0;width:18px}#qr-preview-toggle>.icon{display:block;height:1em;width:auto}#qr-preview-toggle.enabled{opacity:1}#qr-drafts-button{position:relative;display:inline-flex;align-items:center;justify-content:center;height:18px;width:18px;margin:0 1px;line-height:1;text-decoration:none;color:inherit;opacity:.45;transition:opacity .12s}#qr-drafts-button:hover{opacity:1}#qr-drafts-button:has(#qr-drafts-badge:not([hidden])){opacity:.9}#qr-drafts-badge:not([hidden]){position:absolute;top:-4px;right:-4px;min-width:12px;height:12px;padding:0 2px;box-sizing:border-box;border-radius:6px;background:#d33;color:#fff;font-size:8px;line-height:12px;text-align:center;pointer-events:none}.qr-drafts-panel:not([hidden]){position:absolute;top:0;right:calc(100% + 4px);z-index:3;display:flex;flex-direction:column;width:280px;max-height:calc(100vh - 75px);overflow-y:auto;padding:4px;border:1px solid var(--xt-border,rgba(0,0,0,.35));border-radius:3px;background:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));color:var(--xt-menu-fg,var(--xt-header-dialog-fg,inherit));box-shadow:-2px 2px 8px rgba(0,0,0,.35);font-size:11px}.qr-drafts-discard{display:block;padding:3px 4px;margin-bottom:3px;border-bottom:1px solid color-mix(in srgb,currentColor 25%,transparent);text-decoration:none;color:inherit}.qr-drafts-discard:hover,.qr-drafts-row:hover{background:color-mix(in srgb,currentColor 12%,transparent)}.qr-drafts-empty{padding:4px;opacity:.6}.qr-drafts-row{display:flex;align-items:center;gap:3px;padding:2px}.qr-drafts-label{flex:1 1 auto;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:inherit;text-decoration:none;cursor:pointer}.qr-drafts-label:hover{text-decoration:underline}.qr-drafts-act{flex:0 0 auto;text-decoration:none;color:inherit;opacity:.55;padding:0 1px}.qr-drafts-act:hover{opacity:1}.qr-drafts-act.pinned{opacity:1}#qr>.move select[data-name="thread"]{margin-left:auto;min-height:20px;padding:1px 14px 1px 3px}#qr>.move .close{margin-left:3px;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex:0 0 18px;padding:0}#qr>.move .close>.icon{display:block}:root.thread-view #qr:not(.show-new-thread-option)>.move #qr-drafts-button:not([hidden]){margin-left:auto}:root.thread-view #qr:not(.show-new-thread-option)>.move:has(#qr-drafts-button[hidden]) .close{margin-left:auto}#qr .captcha-root{position:relative}:root.sw-yotsuba>body>div:has(iframe[src^="https://www.google.com/recaptcha/"]){transition:none !important}:root.sw-yotsuba>body>div[style*=" top:-10000px;"]:has(iframe[src^="https://www.google.com/recaptcha/"]){visibility:hidden !important}#qr .captcha-container>div{margin:auto;width:304px}:root.ua-blink #qr .captcha-container>div,:root.ua-edge #qr .captcha-container>div{overflow:hidden}:root.ua-blink #qr .captcha-container>div>div:first-of-type,:root.ua-edge #qr .captcha-container>div>div:first-of-type{overflow-y:scroll;overflow-x:hidden;padding-right:30px;height:99%;width:100%}#qr .captcha-counter{display:block;width:100%;text-align:center;pointer-events:none}#qr.captcha-open .captcha-counter{position:absolute;bottom:3px}#qr .captcha-counter>a{pointer-events:auto;display:inline-block}#qr:not(.captcha-open) .captcha-counter>a{display:block;width:100%}#qr.captcha-v2 #qr-captcha-iframe{width:302px;height:423px;border:0;display:block;margin:auto}#qr.captcha-t .fourchanx-captcha-load-hint{display:inline-flex;align-items:center;gap:5px;align-self:center;flex:1 1 140px;min-width:0;min-height:18px;margin-left:6px;font-size:11px;line-height:18px;opacity:.8;white-space:normal;overflow-wrap:anywhere}#qr.captcha-t .fourchanx-captcha-status-text{display:block;align-self:center;min-width:0;min-height:18px;line-height:inherit}#qr.captcha-t .fourchanx-captcha-status-icon{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:13px;height:13px;line-height:0;transform:translateY(-.5px);box-sizing:border-box}#qr.captcha-t .fourchanx-captcha-status-svg{display:block;width:100%;height:100%}#qr.captcha-t .fourchanx-captcha-status-icon.state-complete{color:#2c9c47}#qr.captcha-t .fourchanx-captcha-status-icon.state-failed{color:#cf4a4a}#qr.captcha-t .fourchanx-captcha-status-icon.state-expired{color:#c38c2f}#qr.captcha-t .fourchanx-captcha-status-icon.state-loading{color:#4f7eaa}#qr.captcha-t .fourchanx-captcha-status-icon.state-idle{color:#6f7c8f}#qr.captcha-t .fourchanx-captcha-progress{font-size:11px;line-height:18px}#qr.captcha-t .captcha-container,#qr.captcha-t :is(#t-ctrl,#t-msg,#t-desc,#t-task,#t-load,#t-next,.fourchanx-captcha-load-hint,.fourchanx-captcha-status-text,.fourchanx-captcha-progress){color:inherit !important;-webkit-text-fill-color:currentColor !important}#qr.captcha-t #t-ctrl{padding-left:0 !important;align-items:center !important}#qr.captcha-t .captcha-root>div,#qr.captcha-t .captcha-root>div *{background-color:transparent !important}#qr.captcha-t :is(#t-load,#t-next){background-color:color-mix(in srgb,currentColor 14%,transparent) !important;border:1px solid color-mix(in srgb,currentColor 38%,transparent)}#qr.captcha-t :is(#t-load,#t-next):hover:not(:disabled){background-color:color-mix(in srgb,currentColor 22%,transparent) !important}#qr.captcha-t :is(#t-load,#t-next):disabled{opacity:.6}.goog-bubble-content{max-width:100vw;max-height:100vh;overflow:auto}.goog-bubble-content iframe{position:static !important}#overlay.media-preview{display:flex;overflow:auto;align-items:center}#overlay.media-preview :is(img,video){margin:auto}#overlay.media-preview video{max-height:100%;max-width:100%}#file-n-submit,#qr .oekaki{display:flex;align-items:stretch;margin-top:1px}#qr-file{display:flex;flex:1 1 auto;min-width:0}#qr-file-button,#qr-draw-button{width:15%}#file-n-submit input[type="submit"]{width:25%}#qr-filename-container{flex:1 1 auto;width:0;display:flex;align-items:center;position:relative;padding:1px}input#qr-filename{border:none !important;background:none !important;outline:none}#qr-filename,.has-file #qr-no-file{display:none}#qr-no-file,.has-file #qr-filename{flex:1 1 auto;width:0px;display:inline-block;padding:0;padding-left:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#qr-no-file{color:#AAA}#qr .oekaki.has-file{display:none}#qr .oekaki>label{flex:1 1 auto;width:0;display:flex;align-items:center}#qr .oekaki>label>span{margin:0 3px}#qr .oekaki>label>input{flex:1 1 auto;width:0}#qr .oekaki-bg{position:relative;display:inline-block;width:10%;margin-left:3px}#qr .oekaki-bg>*{position:absolute;top:0;left:0;margin:0}#qr .oekaki-bg>:not([name="oekaki-bgcolor"]){z-index:1}#qr [name="oekaki-bgcolor"]{height:100%;width:100%;border:none;padding:0}#qr [name="oekaki-bg"]:not(:checked)~[name="oekaki-bgcolor"]{visibility:hidden}#qr input[type="file"]{display:none}#qr-filename-container>label,#qr-filename-container>a{flex:none;margin:0;margin-right:3px}#qr:not(.has-spoiler) #qr-spoiler-label,#file-n-submit:not(.has-file) :is(#qr-spoiler-label,#qr-randomize,#qr-restore-name),#file-n-submit:not(.has-image) #qr-jpg,#file-n-submit:not(.has-video) #qr-dogiri-button,#file-n-submit.has-video #qr-oekaki-button,#file-n-submit:not(.has-image):not(.has-video) #qr-view,#file-n-submit.has-file :is(#paste-area,#url-button),#file-n-submit:not(.custom-cooldown) #custom-cooldown-button{display:none}#file-n-submit.has-file:not(:hover):not(:focus-within) #qr-actions{display:none}#qr-filename:focus~#qr-actions{display:none}#qr-filename-container>label{position:relative}#qr-filename-container input[type="checkbox"]{margin:0}.checkbox-letter{font-size:13px;font-weight:bold}#qr-filename-container label:not(:hover)>input[type="checkbox"]:not(:focus):not(:checked),#qr-filename-container label:not(:hover)>input[type="checkbox"]:not(:focus):not(:checked)~:not(.checkbox-letter),#qr-filename-container label:hover>.checkbox-letter,input[type="checkbox"]:focus~.checkbox-letter,input[type="checkbox"]:checked~.checkbox-letter{position:absolute;opacity:0;pointer-events:none}.checkbox-letter,#paste-area,#url-button,#custom-cooldown-button,#dump-button,#split-post{opacity:0.6}#paste-area{font-size:0}#paste-area:focus{opacity:1}#custom-cooldown-button.disabled{opacity:0.27}#qr select{background:var(--xt-background,#fff);border:1px solid var(--xt-border,#CCC);color:var(--xt-menu-fg,inherit)}#qr select[data-name="thread"]{float:right}#qr>form>select{margin-top:1px}.dump #dump-list-container{display:block}#dump-list-container{display:none;position:relative;overflow-y:hidden;margin-top:1px}#dump-list{overflow:auto;white-space:nowrap;width:248px;max-height:248px;min-height:90px;max-width:100%;min-width:100%;display:flex;flex-wrap:wrap}.qr-preview{box-sizing:border-box;counter-increment:thumbnails;cursor:move;display:inline-block;height:90px;width:90px;padding:2px;opacity:.5;overflow:hidden;position:relative;text-shadow:0 0 2px #000;transition:opacity .25s ease-in-out,transform .25s ease-in-out;vertical-align:top;background-size:cover;flex:none}.qr-preview:hover,.qr-preview:focus{opacity:.9}.qr-preview::before{content:counter(thumbnails);color:#fff;position:absolute;top:3px;right:3px;text-shadow:0 0 3px #000,0 0 8px #000}.qr-preview#selected{opacity:1}.qr-preview.drag{box-shadow:0 0 10px rgba(0,0,0,.5);transform:scale(.8)}.qr-preview.over{border-color:#fff;transform:scale(1.1);opacity:0.9;z-index:10}.qr-preview>span{color:#fff}.qr-preview>.remove{background:rgba(0,0,0,0.5);border-radius:2px;color:#ff3b30 !important;left:3px;line-height:1;padding:1px 3px;position:absolute;text-shadow:0 0 2px #000,0 0 8px #000;top:3px;z-index:2}.qr-preview>.remove:hover,.qr-preview>.remove:focus{background:rgba(0,0,0,0.75);color:#ff6e66 !important}.qr-preview>.remove:hover::after{content:" Remove"}.qr-preview:not(.has-file) label,#qr:not(.has-spoiler) .qr-preview-spoiler{display:none}.qr-preview>label{background:rgba(0,0,0,.5);color:#fff;right:0;bottom:0;left:0;position:absolute;text-align:center}.qr-preview>label>input{margin:0}#add-post{cursor:pointer;font-size:1.5em;position:absolute;bottom:20px;right:10px;transform:translateY(calc(-50% - .75em))}#file-n-submit .row{display:flex;flex-direction:row}#qr-actions{display:flex;flex:none;align-items:center;gap:6px;padding:0 3px;white-space:nowrap}#qr-actions .qr-action-button{display:inline-flex;align-items:center}.menu-button{position:relative;cursor:pointer}.postInfo>.menu-button{margin:0 4px}#menu{position:fixed;outline:none;font-weight:normal;transition:opacity .12s ease,transform .12s ease,display .12s allow-discrete}@starting-style{#menu:not(.menu-hidden){opacity:0;transform:scale(.97)}}#menu.menu-hidden{display:none;opacity:0;transform:scale(.97);pointer-events:none}@media (prefers-reduced-motion:reduce){#menu{transition:none}}#menu,.submenu{border-radius:3px;padding-top:1px;padding-bottom:3px}.entry{cursor:pointer;display:block;outline:none;padding:2px 10px;position:relative;text-decoration:none;white-space:nowrap;min-width:70px;text-align:left;text-shadow:none;font-size:10pt}.entry input[type="checkbox"],.entry input[type="radio"]{margin:0px;position:relative;top:2px}.entry input[type="number"]{width:4.5em}.entry.watcher-thumbnail-controls{min-width:270px}.entry.watcher-thumbnail-controls .watcher-thumb-row{display:grid;grid-template-columns:100px max-content;align-items:center;gap:8px;padding:1px 0}.entry.watcher-thumbnail-controls .watcher-thumb-row + .watcher-thumb-row{margin-top:4px}.entry.watcher-thumbnail-controls .watcher-thumb-toggle{display:inline-flex;align-items:center;gap:4px}.entry.watcher-thumbnail-controls .watcher-thumb-slider{display:inline-grid;grid-template-columns:100px 44px 18px;align-items:center;gap:6px}.entry.watcher-thumbnail-controls input[type="range"]{width:100%;min-width:0;margin:0;accent-color:currentColor}.entry.watcher-thumbnail-controls input[type="number"].watcher-thumb-number{box-sizing:border-box;width:44px;min-width:0;padding:1px 4px 1px 2px;font:inherit;line-height:1.2;text-align:right;background-color:transparent;color:inherit;border:1px solid rgba(128,128,128,.45);border-radius:3px}.entry.watcher-thumbnail-controls .watcher-thumb-unit{box-sizing:border-box;display:inline-block;min-width:18px;font-variant-numeric:tabular-nums;line-height:1.2;opacity:.75}.entry.has-shortcut-text,.entry.has-submenu{display:flex;justify-content:space-between;align-items:center}.entry .shortcut-text{opacity:0.5;font-size:70%;margin-left:5px}.entry.scroll-marker-manage-styles{border-top:1px solid color-mix(in srgb,currentColor 25%,transparent);margin-top:3px;padding-top:5px}.menu-indicator{pointer-events:none;position:relative;right:-7px}.left .menu-indicator>.icon{transform:rotate(180deg)}.submenu{display:none;position:absolute;left:100%;top:-1px;margin-left:0px;margin-top:-2px}.focused>.submenu{display:block}.imp-exp-result{position:absolute;text-align:center;margin:auto;right:0px;left:0px;width:200px}.boardTitle,.boardSubtitle{white-space:pre-line}.boardTitle[contenteditable="true"],.boardSubtitle[contenteditable="true"]{cursor:text !important}.embedder:not(.embedded)>span{display:none}#embedding{padding:1px 4px;position:fixed}#embedding.empty{display:none}#embedding>.move{display:flex;justify-content:flex-end;gap:4px}#a-gallery{position:fixed;top:0;bottom:0;left:0;right:0;display:flex;flex-direction:row;background:rgba(0,0,0,0.7)}.gal-viewport{display:flex;align-items:stretch;flex-direction:row;flex:1 1 auto;overflow:hidden}.gal-thumbnails{flex:0 0 var(--gal-thumbs-width,150px);overflow-y:auto;display:flex;flex-direction:column;align-items:stretch;text-align:center;background:rgba(0,0,0,.5);border-left:1px solid #222}.gal-hide-thumbnails .gal-thumbnails{display:none}.gal-grid-thumbnails:not(.gal-hide-thumbnails):not(.gal-fullscreen-thumbs) .gal-thumbnails{display:grid;grid-template-columns:repeat(var(--gal-cols,3),1fr);align-content:start}.gal-grid-thumbnails .gal-thumb{display:flex;align-items:center;justify-content:center}.gal-grid-thumbnails .gal-thumb img,.gal-grid-thumbnails .gal-thumb video{max-width:100%}.gal-fullscreen-thumbs .gal-viewport{flex:0 0 0}.gal-fullscreen-thumbs :is(.gal-image,.gal-prev,.gal-next){display:none}.gal-fullscreen-thumbs .gal-thumbnails{flex:1 1 auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));align-content:start;border-left:none}.gal-fullscreen-thumbs:not(.gal-lightbox-open) .gal-labels{display:none}.gal-fullscreen-thumbs :is(.gal-buttons,.gal-labels){z-index:7}.gal-fullscreen-thumbs.gal-lightbox-open .gal-image{display:flex;position:fixed;inset:0;width:auto;height:auto;z-index:5;background:rgba(0,0,0,0.85)}.gal-fullscreen-thumbs.gal-lightbox-open .gal-prev,.gal-fullscreen-thumbs.gal-lightbox-open .gal-next{display:inline-flex;position:fixed;top:0;bottom:0;z-index:6}.gal-fullscreen-thumbs.gal-lightbox-open .gal-prev{left:0}.gal-fullscreen-thumbs.gal-lightbox-open .gal-next{right:0}:root.gal-thumbs-top #a-gallery,:root.gal-thumbs-bottom #a-gallery{flex-direction:column}:root.gal-thumbs-left .gal-thumbnails,:root.gal-thumbs-top .gal-thumbnails{order:-1}:root.gal-thumbs-left .gal-thumbnails{border-left:none;border-right:1px solid #222}:root.gal-thumbs-top .gal-thumbnails{border-left:none;border-bottom:1px solid #222}:root.gal-thumbs-bottom .gal-thumbnails{border-left:none;border-top:1px solid #222}:root.gal-thumbs-top:not(.gal-fullscreen-thumbs):not(.gal-hide-thumbnails) .gal-thumbnails,:root.gal-thumbs-bottom:not(.gal-fullscreen-thumbs):not(.gal-hide-thumbnails) .gal-thumbnails{display:grid;grid-template-columns:repeat(auto-fill,minmax(125px,1fr));grid-auto-rows:max-content;align-content:start;overflow-x:hidden;overflow-y:auto;scroll-snap-type:y proximity}:root.gal-thumbs-top .gal-thumb,:root.gal-thumbs-bottom .gal-thumb{scroll-snap-align:start}.gal-thumb img,.gal-thumb video{max-width:125px;max-height:125px;height:auto;width:auto}.gal-thumb{flex:0 0 auto;padding:3px;line-height:0;transition:background .2s linear}.gal-highlight{background:rgba(0,190,255,.8)}.gal-prev{border-right:1px solid #222}.gal-next{border-left:1px solid #222}.gal-prev,.gal-next{flex:0 0 20px;position:relative;cursor:pointer;opacity:0.7;background-color:rgba(0,0,0,0.3);display:inline-flex;justify-content:center;align-items:center;font-size:20px;color:#fff}.gal-prev:hover,.gal-next:hover{opacity:1}.gal-image{flex:1 0 auto;display:flex;align-items:flex-start;justify-content:space-around;overflow:hidden;width:1%}:root:not(.gal-fit-height):not(.gal-pdf) .gal-image{overflow-y:scroll !important}:root:not(.gal-fit-width):not(.gal-pdf) .gal-image{overflow-x:scroll !important}.gal-image a{display:flex;align-items:flex-start;margin:auto;line-height:0;max-width:100%}:root.gal-pdf .gal-image a{width:100%;height:100%}.gal-image img,.gal-image video{flex:none}.gal-fit-width .gal-image img,.gal-fit-width .gal-image video{max-width:100%}.gal-fit-height .gal-image img,.gal-fit-height .gal-image video{max-height:calc(100vh - 25px)}.gal-image iframe{width:100%;height:100%}.gal-buttons{font-size:1.5em;margin-right:3px;padding-left:7px;padding-right:7px;top:5px}:root.gal-pdf .gal-buttons{top:40px;background:rgba(0,0,0,0.6) !important;border-radius:3px}.gal-buttons a{color:#ffffff;text-shadow:0px 0px 1px #000000}.gal-buttons.gal-playing>.gal-start,.gal-buttons:not(.gal-playing)>.gal-stop{display:none}.gal-labels{position:fixed;bottom:6px;display:flex;flex-direction:column;align-items:flex-end}:root:not(.show-sauce) .gal-sauce{display:none}.gal-name,.gal-count,.gal-sauce{background:rgba(0,0,0,0.6) !important;border-radius:3px;padding:1px 5px 2px 5px;margin-top:3px;color:#ffffff !important;text-decoration:none !important}.gal-sauce a{color:#ffffff !important}.gal-name:hover,.gal-buttons a:hover,.gal-sauce a:hover{color:rgb(95,95,101) !important}:root.gal-pdf .gal-buttons a:hover{color:rgb(204,204,204) !important}.sauce-container{display:inline}.gal-buttons,.gal-labels{position:fixed;right:44px}:root:not(.gal-fit-width):not(.gal-pdf) .gal-labels{bottom:23px !important}:root.gal-fit-height:not(.gal-pdf) .gal-buttons,:root.gal-fit-height:not(.gal-pdf) .gal-labels{right:28px !important}:root.gal-thumbs-right .gal-buttons,:root.gal-thumbs-right .gal-labels{right:calc(44px + var(--gal-thumbs-width,150px))}:root.gal-thumbs-right.gal-fit-height:not(.gal-pdf) .gal-buttons,:root.gal-thumbs-right.gal-fit-height:not(.gal-pdf) .gal-labels{right:calc(28px + var(--gal-thumbs-width,150px)) !important}:root.gal-thumbs-top .gal-buttons{top:calc(5px + var(--gal-thumbs-width,150px))}:root.gal-thumbs-bottom .gal-labels{bottom:calc(23px + var(--gal-thumbs-width,150px)) !important}.gal-grid-entry{display:flex;align-items:center}.gal-grid-entry>label{display:inline-flex;align-items:center}.gal-col-input{width:3.5em}.gal-field,.gal-field option{background:var(--xt-background,#fff);color:var(--xt-menu-fg,var(--xt-header-dialog-fg,#333));border-color:var(--xt-border,#ccc)}:root.gallery-open.fixed #header-bar:not(.autohide){visibility:hidden}.contact-links{margin-left:2px}.move-note>a{text-decoration:underline}.invisible{font-size:0}.fcx-announcement{text-align:center}.fcx-announcement a{text-decoration:underline}.spin>.icon{animation:spin 2s infinite linear}div.post{overflow:auto;scroll-margin-top:30px}#scroll-markers{position:fixed;top:0;height:100vh;pointer-events:none;z-index:4;background:transparent;box-sizing:border-box}#scroll-markers[hidden]{display:none !important}.scroll-marker{position:absolute;min-height:3px;box-shadow:0 0 0 1px rgba(0,0,0,.35);pointer-events:auto;cursor:pointer;transition:transform .08s ease-out,filter .08s ease-out}.scroll-marker-own{background:var(--xt-scroll-marker-own,var(--xt-border-highlight,#d83030));opacity:var(--xt-scroll-marker-own-opacity,1)}.scroll-marker-you{background:var(--xt-scroll-marker-you,var(--xt-border-highlight,#ff5050));opacity:var(--xt-scroll-marker-you-opacity,1)}.scroll-marker-ghost{background:var(--xt-scroll-marker-ghost,#888888);opacity:var(--xt-scroll-marker-ghost-opacity,1)}:root.scrollbar-markers-offset,:root.scrollbar-markers-offset-single{scrollbar-color:var(--xt-scroll-thumb-bg,var(--sc-titleColor,color-mix(in srgb,currentColor 70%,transparent))) transparent}:root.scrollbar-markers-offset::-webkit-scrollbar-track,:root.scrollbar-markers-offset-single::-webkit-scrollbar-track{background:transparent}:root.scrollbar-markers-offset::-webkit-scrollbar-thumb,:root.scrollbar-markers-offset-single::-webkit-scrollbar-thumb{background:var(--xt-scroll-thumb-bg,var(--sc-titleColor,color-mix(in srgb,currentColor 70%,transparent)))}:root.scrollbar-markers-offset #scroll-markers{right:var(--xt-scrollbar-offset,0px);width:calc(var(--xt-scroll-marker-gutter,6px) * 3)}:root.scrollbar-markers-offset .scroll-marker{border-radius:1px 0 0 1px;transform-origin:right center}:root.scrollbar-markers-offset .scroll-marker:hover{transform:scaleX(1.6);filter:brightness(1.2)}:root.scrollbar-markers-offset-single #scroll-markers{right:var(--xt-scrollbar-offset,0px);width:var(--xt-scroll-marker-track,8px)}:root.scrollbar-markers-offset-single .scroll-marker{left:0;right:0;width:auto;min-height:3px;border-radius:0;transform-origin:center center}:root.scrollbar-markers-offset-single .scroll-marker:hover{transform:scaleY(1.35);filter:brightness(1.2)}:root.scrollbar-markers-over #scroll-markers,:root.scrollbar-markers-over-columns #scroll-markers{right:0;width:var(--xt-scrollbar-width,12px)}:root.scrollbar-markers-over .scroll-marker{left:0;right:0;width:auto;min-height:3px;border-radius:0;transform-origin:center center}:root.scrollbar-markers-over .scroll-marker:hover{transform:scaleY(1.4);filter:brightness(1.3)}:root.scrollbar-markers-over-columns .scroll-marker{min-height:3px;border-radius:0;transform-origin:right center}:root.scrollbar-markers-over-columns .scroll-marker:hover{transform:scaleX(1.6);filter:brightness(1.2)}:root.scrollbar-markers-over,:root.scrollbar-markers-over-columns{scrollbar-width:thin;scrollbar-color:var(--xt-scroll-thumb-bg,var(--sc-titleColor,color-mix(in srgb,currentColor 70%,transparent))) transparent}:root.scrollbar-markers-over::-webkit-scrollbar,:root.scrollbar-markers-over-columns::-webkit-scrollbar{background:transparent}:root.scrollbar-markers-over::-webkit-scrollbar-track,:root.scrollbar-markers-over::-webkit-scrollbar-track-piece,:root.scrollbar-markers-over::-webkit-scrollbar-corner,:root.scrollbar-markers-over-columns::-webkit-scrollbar-track,:root.scrollbar-markers-over-columns::-webkit-scrollbar-track-piece,:root.scrollbar-markers-over-columns::-webkit-scrollbar-corner{background:transparent}:root.scrollbar-markers-over::-webkit-scrollbar-thumb,:root.scrollbar-markers-over-columns::-webkit-scrollbar-thumb{background:var(--xt-scroll-thumb-bg,var(--sc-titleColor,color-mix(in srgb,currentColor 70%,transparent)))}:root.scrollbar-markers-over::-webkit-scrollbar-thumb:hover,:root.scrollbar-markers-over-columns::-webkit-scrollbar-thumb:hover{background:transparent}.entry.scroll-marker-position-option.disabled{pointer-events:none;opacity:.45;cursor:default}.scroll-marker-note{padding:4px 8px;max-width:220px;font-size:11px;line-height:1.35;opacity:.7;white-space:normal}.scroll-marker-unread{left:0;right:0;width:auto;min-height:2px;border-radius:0;box-shadow:0 0 0 1px rgba(0,0,0,.45);background:var(--xt-scroll-marker-unread,#ffd400);opacity:var(--xt-scroll-marker-unread-opacity,1);pointer-events:none}#download-all-picker{position:fixed;z-index:20;padding:0;min-width:240px;color:var(--xt-menu-fg,var(--xt-header-dialog-fg,inherit))}#download-all-picker .move{padding:4px 8px;font-weight:bold;display:flex;align-items:center;justify-content:space-between;gap:8px}#download-all-picker .da-body{padding:8px 10px 10px}#download-all-picker .da-buttons{display:flex;gap:6px;margin-bottom:8px}#download-all-picker .da-buttons button{flex:1;padding:4px 8px;cursor:pointer;display:flex;flex-direction:column;align-items:flex-start;gap:2px;line-height:1.2;appearance:none;-webkit-appearance:none;font:inherit;text-align:left;background-color:var(--xt-inline,color-mix(in srgb,var(--xt-border,currentColor) 14%,var(--xt-background,inherit)));border:1px solid var(--xt-border,color-mix(in srgb,currentColor 28%,transparent));color:inherit;transition:background-color .2s,border-color .2s}#download-all-picker .da-buttons button:hover:not(:disabled){background-color:var(--xt-entry-focus-bg,color-mix(in srgb,currentColor 18%,transparent));border-color:var(--xt-border-field-focus,var(--xt-border,currentColor))}#download-all-picker .da-buttons button:disabled{opacity:.45;cursor:not-allowed}#download-all-picker .da-buttons .da-btn-title{font-weight:bold}#download-all-picker .da-buttons .da-btn-count,#download-all-picker .da-buttons .da-btn-size{font-size:11px;opacity:.82}.da-progress-cancel{margin-left:6px}#dogiri{position:fixed;inset:0;z-index:2147483600;display:flex;flex-direction:column;align-items:center;justify-content:center;background:rgba(0,0,0,0.85);color:var(--xt-text-color,inherit);font-size:13px}:root.dogiri-open #header-bar,:root.dogiri-open #notifications{z-index:2147483601}#dogiri .dogiri-header,#dogiri .dogiri-body{width:min(var(--dogiri-width,900px),96vw);box-sizing:border-box}#dogiri-resize{position:absolute;top:0;bottom:0;right:-5px;width:10px;cursor:ew-resize;touch-action:none;z-index:3}#dogiri-resize:hover,#dogiri-resize:active{background:linear-gradient(to right,transparent 3px,color-mix(in srgb,var(--xt-dogiri-accent,#6a5acd) 60%,transparent) 3px 7px,transparent 7px)}.dogiri-header{display:flex;align-items:center;gap:8px;padding:5px 10px;background:var(--xt-dialog-bg,var(--xt-background,#1e1e23));border:1px solid var(--xt-border,#34343c);border-bottom:0;border-radius:4px 4px 0 0}.dogiri-title{font-weight:bold}#dogiri-wip{font-weight:normal;font-size:0.85em;opacity:0.6}#dogiri-filename{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:0.8}#dogiri-duration{opacity:0.8;font-variant-numeric:tabular-nums}#dogiri-close{text-decoration:none;color:inherit;padding:0 4px}.dogiri-body{position:relative;display:flex;flex-direction:column;gap:8px;padding:10px;background:var(--xt-dialog-bg,var(--xt-background,#17171b));border:1px solid var(--xt-border,#34343c);border-radius:0 0 4px 4px}#dogiri-confirm{position:absolute;right:10px;bottom:46px;max-width:340px;display:flex;flex-direction:column;gap:8px;padding:10px;background:var(--xt-dialog-bg,var(--xt-background,#1e1e23));border:1px solid var(--xt-dogiri-accent,#6a5acd);border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,0.5);z-index:5}#dogiri-confirm[hidden]{display:none}#dogiri-confirm.dogiri-confirm-top{top:8px;bottom:auto}#dogiri-confirm-remember-row{display:flex;align-items:center;gap:5px;font-size:90%;cursor:pointer}#dogiri-confirm-remember-row[hidden]{display:none}.dogiri-confirm-buttons{display:flex;gap:8px;justify-content:flex-end}#dogiri-export-menu{position:absolute;right:10px;bottom:46px;display:flex;flex-direction:column;gap:8px;padding:10px;background:var(--xt-dialog-bg,var(--xt-background,#1e1e23));border:1px solid var(--xt-border,#3a3a44);border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,0.5);z-index:2}#dogiri-export-menu[hidden]{display:none}.dogiri-menu-row{display:flex;align-items:center;gap:8px;white-space:nowrap;cursor:pointer}.dogiri-menu-row[hidden]{display:none}#dogiri-fit-target{width:70px}.dogiri-button[hidden]{display:none}#dogiri-export-group{display:inline-flex}#dogiri-export-group[hidden]{display:none}#dogiri-export-group #dogiri-export{border-radius:3px 0 0 3px}#dogiri-export-arrow{border-radius:0 3px 3px 0;border-left:0;padding:3px 5px}#dogiri-export-target-menu{position:absolute;right:10px;bottom:46px;display:flex;flex-direction:column;gap:4px;padding:6px;background:var(--xt-dialog-bg,var(--xt-background,#1e1e23));border:1px solid var(--xt-border,#3a3a44);border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,0.5);z-index:2}#dogiri-export-target-menu[hidden]{display:none}#dogiri-save-panel,#dogiri-projects-menu{position:absolute;right:10px;top:8px;display:flex;flex-direction:column;gap:8px;padding:10px;background:var(--xt-dialog-bg,var(--xt-background,#1e1e23));border:1px solid var(--xt-border,#3a3a44);border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,0.5);z-index:4}#dogiri-save-panel[hidden],#dogiri-projects-menu[hidden]{display:none}#dogiri-projects-menu{gap:2px;min-width:260px;max-height:50vh;overflow-y:auto}.dogiri-projects-empty{opacity:0.7;padding:2px 4px}.dogiri-project-row{display:flex;align-items:center;gap:4px}.dogiri-project-row .dogiri-menu-item{flex:1;display:flex;flex-direction:column}.dogiri-project-meta{font-size:11px;opacity:0.7}.dogiri-project-rm{text-decoration:none;color:inherit;opacity:0.6;padding:0 4px}.dogiri-project-rm:hover{opacity:1}.dogiri-menu-item{padding:3px 8px;border-radius:3px;text-decoration:none;color:inherit;white-space:nowrap}.dogiri-menu-item:hover{background:var(--xt-entry-focus-bg,rgba(127,127,127,0.25))}#dogiri-preview-box{position:relative;display:flex;justify-content:center;background:#000;border-radius:3px;overflow:hidden}#dogiri-video{max-width:100%;max-height:50vh}#dogiri-caption-overlay{position:absolute;inset:0;pointer-events:none}.dogiri-preview-caption{position:absolute;text-align:center;color:#fff;font-weight:bold;text-shadow:0 1px 2px #000,0 0 6px #000;cursor:ns-resize;user-select:none;touch-action:none;pointer-events:auto}.dogiri-toolbar,#dogiri-caption-editor,.dogiri-output{display:flex;align-items:center;gap:8px}.dogiri-toolbar-spacer{flex:1}.dogiri-button{padding:3px 9px;border:1px solid var(--xt-border,#3a3a44);border-radius:3px;background:var(--xt-background,#26262e);text-decoration:none;color:inherit;white-space:nowrap;cursor:pointer}.dogiri-button:hover{border-color:var(--xt-border-highlight,#56565f)}.dogiri-primary{border-color:var(--xt-dogiri-accent,#6a5acd)}.dogiri-pressed{background:var(--xt-dogiri-accent,#6a5acd);color:#fff}.dogiri-button.disabled{opacity:0.4;cursor:default}#dogiri-time{min-width:52px;font-variant-numeric:tabular-nums;opacity:0.8}#dogiri-caption-text{flex:1}#dogiri-clip-times{display:flex;align-items:center;gap:4px;font-variant-numeric:tabular-nums}#dogiri-clip-times input{width:52px;text-align:center}#dogiri-timeline{position:relative;height:var(--dogiri-timeline-height,62px);background:var(--xt-background,#1b1b21);border:1px solid var(--xt-border,#2c2c34);border-radius:3px;cursor:pointer;touch-action:none}#dogiri-strip{position:absolute;inset:20px 0 8px;background:repeating-linear-gradient(90deg,rgba(128,128,128,0.25) 0 24px,rgba(128,128,128,0.12) 24px 48px);pointer-events:none;overflow:hidden}#dogiri-strip-canvas{display:block;width:100%;height:100%}#dogiri-regions{position:absolute;inset:14px 0 0;pointer-events:none}.dogiri-region{position:absolute;top:0;bottom:0;box-sizing:border-box;background:color-mix(in srgb,var(--xt-dogiri-accent,#7a5cff) 30%,transparent);border-left:2px solid color-mix(in srgb,var(--xt-dogiri-accent,#7a5cff) 78%,#fff);border-right:2px solid color-mix(in srgb,var(--xt-dogiri-accent,#7a5cff) 78%,#fff);cursor:grab;pointer-events:auto}.dogiri-region.alt{background:color-mix(in srgb,var(--xt-dogiri-accent-alt,#3fbfae) 28%,transparent);border-left-color:var(--xt-dogiri-accent-alt,#3fbfae);border-right-color:var(--xt-dogiri-accent-alt,#3fbfae)}.dogiri-region:active{cursor:grabbing}.dogiri-region.selected{background:color-mix(in srgb,var(--xt-dogiri-accent,#7a5cff) 45%,transparent);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.4)}.dogiri-region.alt.selected{background:color-mix(in srgb,var(--xt-dogiri-accent-alt,#3fbfae) 42%,transparent)}.dogiri-region-handle{position:absolute;top:0;bottom:0;width:12px;cursor:ew-resize}.dogiri-region-handle[data-edge="start"]{left:-6px}.dogiri-region-handle[data-edge="end"]{right:-6px}#dogiri-region-menu{position:absolute;top:-5px;transform:translate(-50%,-100%);display:flex;align-items:center;gap:6px;padding:2px 4px 2px 8px;background:var(--xt-dialog-bg,var(--xt-background,#1e1e23));border:1px solid var(--xt-border,#3a3a44);border-radius:3px;box-shadow:0 2px 8px rgba(0,0,0,0.4);white-space:nowrap;cursor:default;z-index:3}#dogiri-region-menu[hidden]{display:none}#dogiri-region-menu::after{content:'';position:absolute;top:100%;left:50%;margin-left:-4px;border:4px solid transparent;border-top-color:var(--xt-border,#3a3a44)}#dogiri-region-menu-label{font-size:11px;opacity:0.8}#dogiri-region-menu-rm{text-decoration:none;color:inherit;padding:1px 6px;border-radius:3px}#dogiri-region-menu-rm:hover{background:color-mix(in srgb,#f33 25%,transparent)}#dogiri-playhead{position:absolute;top:14px;bottom:0;width:2px;margin-left:-1px;background:var(--xt-dogiri-playhead,#e33);pointer-events:none}#dogiri-scrub-handle{position:absolute;top:0;width:16px;height:14px;margin-left:-8px;display:flex;align-items:center;justify-content:center;background:var(--xt-background,#26262e);border:1px solid var(--xt-dogiri-playhead,#e33);border-radius:3px 3px 2px 2px;color:var(--xt-dogiri-playhead,#e33);font-size:8px;line-height:1;cursor:grab;user-select:none;z-index:1}#dogiri-scrub-handle:active{cursor:grabbing}#dogiri-caption-lane{position:relative;height:22px;background:var(--xt-background,#1b1b21);border:1px solid var(--xt-border,#2c2c34);border-radius:3px;cursor:crosshair;touch-action:none}.dogiri-caption-block{position:absolute;top:2px;height:18px;min-width:8px;padding:0 4px;overflow:hidden;white-space:nowrap;font-size:11px;line-height:16px;background:var(--xt-dogiri-accent,#6a5acd);border:1px solid rgba(0,0,0,0.45);border-radius:2px;color:#fff;cursor:grab;box-sizing:border-box}.dogiri-caption-block:hover{padding-right:14px}.dogiri-caption-block[data-hue="1"]{filter:hue-rotate(72deg)}.dogiri-caption-block[data-hue="2"]{filter:hue-rotate(144deg)}.dogiri-caption-block[data-hue="3"]{filter:hue-rotate(216deg)}.dogiri-caption-block[data-hue="4"]{filter:hue-rotate(288deg)}.dogiri-caption-handle{position:absolute;top:0;bottom:0;width:10px;cursor:ew-resize}.dogiri-caption-handle[data-edge="start"]{left:-5px}.dogiri-caption-handle[data-edge="end"]{right:-5px}.dogiri-caption-rm{position:absolute;top:0;right:2px;text-decoration:none;color:inherit;opacity:0}.dogiri-caption-block.editing{outline:2px solid var(--xt-text-color,currentColor);outline-offset:1px}.dogiri-caption-block:hover .dogiri-caption-rm{opacity:0.9}.dogiri-caption-rm:hover{opacity:1}.dogiri-caption-ghost{position:absolute;top:2px;bottom:2px;box-sizing:border-box;border:1px dashed color-mix(in srgb,var(--xt-dogiri-accent,#8f6aff) 78%,#fff);background:color-mix(in srgb,var(--xt-dogiri-accent,#7a5cff) 15%,transparent);border-radius:2px;pointer-events:none}#dogiri-stats{opacity:0.8;font-variant-numeric:tabular-nums}#dogiri .field,#dogiri select{background:var(--xt-background,#121216);color:inherit;border:1px solid var(--xt-border,#3a3a44);border-radius:3px;padding:2px 6px;font-size:13px}#dogiri-toasts{position:absolute;left:50%;bottom:8px;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:4px;max-width:92%;z-index:2}.dogiri-toast{padding:5px 12px;border-radius:2px;color:#fff;font-weight:700;text-shadow:0 1px 2px rgba(0,0,0,.5);box-shadow:0 1px 2px rgba(0,0,0,.15);cursor:pointer}.dogiri-toast.error{background-color:hsla(0,100%,38%,.9)}.dogiri-toast.warning{background-color:hsla(36,100%,38%,.9)}.dogiri-toast.info{background-color:hsla(200,100%,38%,.9)}.dogiri-toast.success{background-color:hsla(104,100%,38%,.9)}#dogiri-clip-panel{position:absolute;top:8px;right:8px;bottom:8px;width:250px;max-width:calc(100% - 16px);display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--xt-dialog-bg,var(--xt-background,#1e1e23));border:1px solid var(--xt-border,#3a3a44);border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,0.5);box-sizing:border-box;z-index:4;animation:dogiri-slide-in .15s ease}#dogiri-clip-panel[hidden]{display:none}@keyframes dogiri-slide-in{from{transform:translateX(16px);opacity:0}to{transform:none;opacity:1}}.dogiri-clip-panel-header{display:flex;align-items:center;gap:6px;padding:0 4px}.dogiri-clip-panel-header label{flex:1;display:flex;align-items:center;gap:6px;cursor:pointer}#dogiri-clips-close{text-decoration:none;color:inherit;padding:0 4px}#dogiri-clips-count{opacity:0.8;font-variant-numeric:tabular-nums}#dogiri-clip-rows{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:1px}.dogiri-clips-empty{opacity:0.7;padding:2px 4px}.dogiri-clip-row{display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:3px;cursor:pointer;white-space:nowrap}.dogiri-clip-row:hover{background:var(--xt-entry-focus-bg,rgba(127,127,127,0.25))}.dogiri-clip-row.selected{background:color-mix(in srgb,var(--xt-dogiri-accent,#6a5acd) 30%,transparent)}.dogiri-clip-meta{margin-left:auto;font-size:11px;opacity:0.7;font-variant-numeric:tabular-nums}.dogiri-clip-panel-actions{display:flex;gap:6px;justify-content:flex-end}#dogiri-clip-rows::-webkit-scrollbar,#dogiri-projects-menu::-webkit-scrollbar{width:4px}#dogiri-clip-rows::-webkit-scrollbar-button,#dogiri-projects-menu::-webkit-scrollbar-button{display:none;width:0;height:0}#dogiri-clip-rows::-webkit-scrollbar-track,#dogiri-projects-menu::-webkit-scrollbar-track{background:transparent}#dogiri-clip-rows::-webkit-scrollbar-thumb,#dogiri-projects-menu::-webkit-scrollbar-thumb{background-color:var(--xt-border,rgba(128,128,128,.5));border-radius:3px}#dogiri-clip-rows::-webkit-scrollbar-thumb:hover,#dogiri-projects-menu::-webkit-scrollbar-thumb:hover{background-color:var(--xt-border,rgba(128,128,128,.8))}@supports not selector(::-webkit-scrollbar){#dogiri-clip-rows,#dogiri-projects-menu{scrollbar-width:thin;scrollbar-color:var(--xt-border,rgba(128,128,128,.5)) transparent}}:root.xt-mobile input:not([type=checkbox]):not([type=radio]),:root.xt-mobile textarea,:root.xt-mobile select{font-size:16px}:root.xt-mobile #header-bar{display:flex;flex-direction:column;gap:2px;padding:4px 8px 6px;z-index:8}:root.xt-mobile.xt-bar-split.xt-icons-bottom #header-bar{position:fixed;top:0;left:0;right:0}:root.xt-mobile #board-list a{display:inline-block;padding:3px 4px;line-height:1.3}:root.xt-mobile #shortcuts{float:none;flex-wrap:wrap;justify-content:flex-end;align-items:center;align-self:stretch;gap:4px;margin-right:0}:root.xt-mobile #shortcuts a,:root.xt-mobile #shortcuts .menu-button,:root.xt-mobile #mobile-bar a,:root.xt-mobile #mobile-bar .menu-button{display:inline-flex;align-items:center;justify-content:center;min-width:34px;min-height:34px}:root.xt-mobile #shortcuts .icon,:root.xt-mobile #mobile-bar .icon{font-size:19px}:root.xt-mobile .qr-link-container,:root.xt-mobile #togglePostFormLink,:root.xt-mobile .navLinks.json-index .brackets-wrap,:root.xt-mobile #scroll-markers,:root.xt-mobile body>hr{display:none}:root.xt-mobile .navLinks.json-index{display:flex;padding:0}:root.xt-mobile #index-search-toggle{display:inline-flex;align-items:center;justify-content:center}:root.xt-mobile.xt-index-search-open #shortcut-index-search,:root.xt-mobile.xt-index-searching #shortcut-index-search{background:rgba(128,128,128,.22);border-radius:4px}:root.xt-mobile.xt-index-search-open #header-bar,:root.xt-mobile.xt-boardpicker-open #header-bar,:root.xt-mobile.xt-index-search-open #header-bar:not(:hover),:root.xt-mobile.xt-boardpicker-open #header-bar:not(:hover){z-index:35}:root.xt-mobile #index-search-row{display:none;position:absolute;top:100%;left:0;right:0;z-index:30;box-sizing:border-box;align-items:center;gap:6px 8px;padding:6px;cursor:auto;background:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));border-bottom:1px solid var(--xt-border,rgba(128,128,128,.45));box-shadow:0 8px 24px rgba(0,0,0,.35)}:root.xt-mobile.xt-index-search-open #index-search-row{display:flex;transition:opacity .12s ease,transform .12s ease}@starting-style{:root.xt-mobile.xt-index-search-open #index-search-row{opacity:0;transform:translateY(-8px)}}:root.xt-mobile.xt-icons-top #index-search-row{top:auto;bottom:100%;border-bottom:none;border-top:1px solid var(--xt-border,rgba(128,128,128,.45));box-shadow:0 -8px 24px rgba(0,0,0,.35)}@starting-style{:root.xt-mobile.xt-icons-top.xt-index-search-open #index-search-row{transform:translateY(8px)}}:root.xt-mobile #index-search-help-wrap{position:static;display:inline-flex;flex:0 0 auto;order:2}:root.xt-mobile #index-search-help{width:44px;height:44px;margin:0}:root.xt-mobile #index-search-help .icon{font-size:20px}:root.xt-mobile #index-search-help-popover{top:100%;left:0;right:0;z-index:46;width:auto;max-height:60vh;padding:10px 14px;font-size:13px;line-height:1.5;border-radius:0 0 12px 12px;box-shadow:0 8px 24px rgba(0,0,0,.35)}:root.xt-mobile #index-search-help-popover code{font-size:13px}:root.xt-mobile #index-search-help-popover strong.section,:root.xt-mobile #index-search-help-popover span.note{font-size:12px}:root.xt-mobile.xt-icons-top #index-search-help-popover{top:auto;bottom:100%;border-radius:12px 12px 0 0;box-shadow:0 -8px 24px rgba(0,0,0,.35)}:root.xt-mobile #hidden-label:not([hidden]){display:flex;align-items:center;min-width:0;min-height:44px;font-size:0}:root.xt-mobile #hidden-count{font-size:15px}:root.xt-mobile #hidden-toggle{font-size:0;margin-left:auto}:root.xt-mobile #hidden-toggle a{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;padding:0 10px;font-size:15px}:root.xt-mobile #index-search{flex:1 1 auto;width:auto;min-width:0;box-sizing:border-box;min-height:44px;order:1}:root.xt-mobile #index-search-clear{order:1;align-items:center;min-height:44px;padding:0 4px;font-size:18px}:root.xt-mobile #index-search:focus,:root.xt-mobile #index-search[data-searching]{width:auto}:root.xt-mobile #index-controls-toggle{display:inline-flex;align-items:center;justify-content:center}:root.xt-mobile #index-options{display:none}:root.xt-mobile.index-options-open #index-options{position:fixed;top:var(--xt-mobile-header-h,45px);left:0;right:0;z-index:46;display:flex;flex-direction:column;gap:4px;box-sizing:border-box;width:100%;max-height:calc(100dvh - var(--xt-mobile-header-h,45px) - var(--xt-mobile-bar-h));overflow-y:auto;overscroll-behavior:contain;padding:8px 14px 14px;background:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));border-bottom:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:0 0 12px 12px;box-shadow:0 12px 24px rgba(0,0,0,.4);transition:opacity .12s ease,transform .12s ease}@starting-style{:root.xt-mobile.index-options-open #index-options{opacity:0;transform:translateY(-24px)}:root.xt-mobile.xt-icons-top.index-options-open #index-options{transform:translateY(24px)}}:root.xt-mobile.xt-icons-top.index-options-open #index-options{top:auto;bottom:calc(var(--xt-mobile-header-h,45px) + env(safe-area-inset-bottom,0px));border-bottom:none;border-top:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:12px 12px 0 0;box-shadow:0 -12px 24px rgba(0,0,0,.4)}:root.xt-mobile.index-options-open #index-options-backdrop{display:block;position:fixed;inset:0;z-index:45;background:transparent}:root.xt-mobile .index-options-header{display:flex;align-items:center;justify-content:space-between;font-weight:bold;padding:4px 0 0}:root.xt-mobile .index-options-header a{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;font-size:22px;font-weight:normal}:root.xt-mobile .index-option-row{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:44px}:root.xt-mobile .index-option-label{display:block;flex:0 0 auto}:root.xt-mobile .index-option-row select{flex:1 1 auto;min-width:0;max-width:62%;min-height:44px;padding:6px 8px}:root.xt-mobile #index-rev{width:24px;height:24px;margin:0 2px}:root.xt-mobile #download-all-picker{left:0 !important;right:0 !important;top:auto !important;bottom:var(--xt-bar-bottom-inset,0px) !important;width:100% !important;min-width:0;max-width:none !important;box-sizing:border-box;max-height:75vh;overflow-y:auto;z-index:45;box-shadow:0 -4px 16px rgba(0,0,0,.35)}:root.xt-mobile #download-all-picker .move{cursor:default;padding:8px 10px}:root.xt-mobile #download-all-picker .move .close{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px}:root.xt-mobile #download-all-picker .da-body{padding:8px 10px calc(10px + env(safe-area-inset-bottom,0px))}:root.xt-mobile #download-all-picker .da-buttons button{min-height:56px;padding:8px 10px}:root.xt-mobile #a-gallery{background:#000}:root.xt-mobile:not(.gal-pdf) .gal-image{overflow:hidden !important;touch-action:pan-y}:root.xt-mobile .gal-image a{width:100%;height:100%;align-items:center;justify-content:center}:root.xt-mobile .gal-image img,:root.xt-mobile .gal-image video{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain}:root.xt-mobile.gallery-open .gal-buttons{top:8px !important;right:8px !important;margin-right:0;display:flex;align-items:center;background:rgba(0,0,0,.5);border-radius:6px;padding:0 2px;z-index:3}:root.xt-mobile .gal-buttons a{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px}:root.xt-mobile.gallery-open .gal-labels{left:8px;right:auto !important;bottom:calc(var(--gal-thumbs-width,96px) + 10px) !important;align-items:flex-start;max-width:calc(100vw - 16px);font-size:12px;z-index:3}:root.xt-mobile .gal-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:calc(100vw - 16px)}:root.xt-mobile .gal-prev,:root.xt-mobile .gal-next{position:fixed;top:50%;transform:translateY(-50%);flex:none;width:44px;height:64px;border:none;background-color:rgba(0,0,0,.45);font-size:26px;z-index:3}:root.xt-mobile .gal-prev{left:0;border-radius:0 8px 8px 0}:root.xt-mobile .gal-next{right:0;border-radius:8px 0 0 8px}:root.xt-mobile.gal-thumbs-bottom:not(.gal-fullscreen-thumbs):not(.gal-hide-thumbnails) .gal-thumbnails{display:flex;flex-direction:row;align-items:center;overflow-x:auto;overflow-y:hidden;scroll-snap-type:x proximity}:root.xt-mobile .gal-thumb{scroll-snap-align:center}:root.xt-mobile .gal-thumb img,:root.xt-mobile .gal-thumb video{max-width:84px;max-height:84px}:root.xt-mobile #qr{left:0 !important;right:0 !important;top:auto !important;bottom:max(var(--qr-kb-inset,0px),var(--xt-bar-bottom-inset,0px)) !important;width:100% !important;min-width:0 !important;max-width:none !important;box-sizing:border-box}:root.xt-mobile #thread-watcher{position:fixed;left:0 !important;right:0 !important;top:var(--xt-bar-top-inset,0px) !important;bottom:var(--xt-bar-bottom-inset,0px) !important;width:100% !important;max-width:none !important;max-height:none;height:auto;box-sizing:border-box;padding:calc(6px + env(safe-area-inset-top,0px)) 8px 0;overflow:hidden;z-index:45;border:none;border-radius:0;box-shadow:none;white-space:normal}:root.xt-mobile #thread-watcher:not([hidden]){display:flex;flex-direction:column}:root.xt-mobile #thread-watcher>.move{gap:10px;padding:4px 2px 8px;cursor:default;flex:0 0 auto}:root.xt-mobile #thread-watcher>.move>a{width:34px;height:34px;flex:0 0 34px}:root.xt-mobile #thread-watcher>.move>a>.icon{font-size:17px}:root.xt-mobile #thread-watcher #watched-threads{max-height:none;flex:1 1 auto;overflow-y:auto;overscroll-behavior:contain;padding-bottom:calc(12px + env(safe-area-inset-bottom,0px))}:root.xt-mobile #thread-watcher #watched-threads>div{padding:8px 4px;gap:8px;cursor:default}:root.xt-mobile #thread-watcher #watched-threads .watcher-link{max-width:none;flex:1;min-width:0}:root.xt-mobile #thread-watcher.watcher-grid #watched-threads{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;align-content:start;padding:4px 2px calc(12px + env(safe-area-inset-bottom,0px))}:root.xt-mobile #thread-watcher.watcher-grid #watched-threads>div{display:flex;flex-direction:column;padding:0;position:relative;min-width:0}:root.xt-mobile #thread-watcher.watcher-grid #watched-threads>div>a:not(.watcher-link){display:none}:root.xt-mobile #thread-watcher.watcher-grid #watched-threads .watcher-link{display:flex;flex-direction:column;position:relative;min-width:0}:root.xt-mobile #thread-watcher.watcher-grid #watched-threads .watcher-thumb{display:block;width:100%;height:auto;aspect-ratio:1 / 1;object-fit:cover;border-radius:10px;margin-right:0}:root.xt-mobile #thread-watcher.watcher-grid #watched-threads>div.current .watcher-thumb{outline:2px solid var(--xt-link,#57a);outline-offset:-2px}:root.xt-mobile #thread-watcher.watcher-grid #watched-threads>div.dead-thread .watcher-thumb{filter:grayscale(1)}:root.xt-mobile #thread-watcher.watcher-grid #watched-threads .watcher-unread{position:absolute;top:6px;right:6px;z-index:1;margin:0;padding:2px 8px;border-radius:999px;background:rgba(0,0,0,.65);color:#fff;font-size:12px;line-height:1.4}:root.xt-mobile #thread-watcher.watcher-grid #watched-threads .watcher-page{position:absolute;top:6px;left:6px;z-index:1;margin:0;padding:2px 6px;border-radius:6px;background:rgba(0,0,0,.5);color:#fff;font-size:11px;line-height:1.4}:root.xt-mobile #thread-watcher.watcher-grid #watched-threads .watcher-title{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:normal;word-break:break-word;font-size:13px;line-height:1.35;padding:6px 2px 2px;margin-left:0}:root.xt-mobile #qr textarea.field{width:100% !important;min-width:0;box-sizing:border-box;resize:vertical}:root.xt-mobile #qr.qr-mobile{z-index:50;box-shadow:0 -4px 16px rgba(0,0,0,.35);border-radius:0}:root.xt-mobile #qr.qr-mobile .qr-mobile-handle{display:flex;align-items:center;justify-content:center;height:26px;font-size:15px;text-decoration:none}:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded) .qr-mobile-handle .icon{transform:rotate(180deg)}:root.xt-mobile #qr.qr-mobile>form{display:flex;flex-direction:column;gap:6px;padding:0 8px 8px;max-height:calc(100dvh - 90px - max(var(--qr-kb-inset,0px),var(--xt-bar-bottom-inset,0px)))}:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded)>.move>*:not(.close),:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded) .persona,:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded) #dump-list-container,:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded) .oekaki,:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded) .flagSelector-picker,:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded) select[name="filetag"],:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded) #qr-drafts-panel{display:none}:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded):not(.reply-to-thread):not(.forced-anon) .persona{display:flex}:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded):not(.reply-to-thread):not(.forced-anon) .persona>:not(input[data-name="sub"]){display:none}:root.xt-mobile #qr.qr-mobile>.move{gap:8px;min-height:44px;padding:0 8px;cursor:default}:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded)>.move{position:absolute;top:0;right:2px;min-height:0;padding:0;z-index:3}:root.xt-mobile #qr.qr-mobile:not(.qr-mobile-expanded)>.move .close{width:34px;height:30px;flex:0 0 34px;font-size:14px}:root.xt-mobile #qr.qr-mobile>.move label:has(>#autohide){display:none}:root.xt-mobile #qr.qr-mobile>.move select[data-name="thread"]{flex:1;min-width:0;min-height:38px}:root.xt-mobile #qr.qr-mobile>.move #qr-preview-toggle,:root.xt-mobile #qr.qr-mobile>.move #qr-drafts-button,:root.xt-mobile #qr.qr-mobile>.move .close{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;flex:0 0 40px;font-size:16px}:root.xt-mobile #qr.qr-mobile .persona{display:flex;flex-wrap:wrap;gap:6px}:root.xt-mobile #qr.qr-mobile .persona input.field{flex:1 1 100%;min-height:40px;box-sizing:border-box}:root.xt-mobile #qr.qr-mobile .persona .qr-button{min-width:44px;min-height:40px}:root.xt-mobile #qr.qr-mobile .textarea{width:100%}:root.xt-mobile #qr.qr-mobile #file-n-submit{display:block;position:relative}:root.xt-mobile #qr.qr-mobile #qr-file{display:flex;align-items:center;justify-content:space-between;gap:6px}:root.xt-mobile #qr.qr-mobile #qr-file-button{flex:0 0 auto;height:44px;min-width:64px}:root.xt-mobile #qr.qr-mobile.qr-has-file #qr-file-button{background-color:rgba(128,128,128,.3);font-weight:700}:root.xt-mobile #qr.qr-mobile #qr-file [type="submit"]{flex:0 0 auto;height:44px;min-width:84px}:root.xt-mobile #qr.qr-mobile #qr-filename-container{display:flex;flex:1 1 auto;align-items:center;gap:4px;min-width:0;min-height:44px;box-sizing:border-box}:root.xt-mobile #qr.qr-mobile #qr-filename{flex:1 1 auto;width:0;min-width:0;min-height:36px}:root.xt-mobile #qr.qr-mobile #qr-filename-container .qr-action-button,:root.xt-mobile #qr.qr-mobile.has-spoiler #qr-spoiler-label{display:inline-flex;align-items:center;justify-content:center;min-width:36px;min-height:36px;font-size:15px}:root.xt-mobile #qr.qr-mobile #dump-list-container{overflow-y:visible}:root.xt-mobile #qr.qr-mobile #dump-list{display:flex;flex-direction:column;gap:6px;width:100%;min-height:0;max-height:30vh;overflow:hidden auto;overscroll-behavior:contain;white-space:normal}:root.xt-mobile #qr.qr-mobile .qr-preview{display:flex;flex-direction:column;justify-content:center;width:100%;height:auto;min-height:52px;flex:none;cursor:default;opacity:.75;padding:8px 88px 8px 68px;border:1px solid var(--xt-border,rgba(128,128,128,.45));border-left:3px solid transparent;border-radius:8px;text-shadow:none;text-decoration:none;background-color:rgba(128,128,128,.08);background-repeat:no-repeat;background-position:10px center;background-size:auto 40px}:root.xt-mobile #qr.qr-mobile .qr-preview#selected{opacity:1;border-left-color:var(--xt-qr-preview-accent,#ff8c00);background-color:rgba(128,128,128,.2)}:root.xt-mobile #qr.qr-mobile .qr-preview::before{top:50%;right:48px;transform:translateY(-50%);color:inherit;opacity:.5;text-shadow:none}:root.xt-mobile #qr.qr-mobile .qr-preview>.remove{left:auto;right:6px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;font-size:15px}:root.xt-mobile #qr.qr-mobile .qr-preview>.qr-preview-spoiler,:root.xt-mobile #qr.qr-mobile .qr-preview>br{display:none}:root.xt-mobile #qr.qr-mobile .qr-preview>span{color:inherit;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:root.xt-mobile #qr.qr-mobile .qr-preview>#qr-preview-name{display:none}:root.xt-mobile #qr.qr-mobile .qr-preview>#qr-preview-comment{font-size:12px;opacity:.6}:root.xt-mobile #qr.qr-mobile .qr-preview::after{content:attr(title);order:-1;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}:root.xt-mobile #qr.qr-mobile .qr-preview:not([title])::after{content:"No file";opacity:.5}:root.xt-mobile #qr.qr-mobile .qr-preview>.remove{background:rgba(128,128,128,.15);border-radius:8px}:root.xt-mobile #qr.qr-mobile #add-post{position:static;transform:none;display:flex;align-items:center;justify-content:center;min-height:44px;margin-top:6px;border:1px dashed var(--xt-border,rgba(128,128,128,.45));border-radius:8px;font-size:22px;text-decoration:none}:root.xt-mobile #qr.qr-mobile .field,:root.xt-mobile #qr.qr-mobile select,:root.xt-mobile #qr.qr-mobile .qr-button{border-radius:8px}:root.xt-mobile #qr.qr-mobile textarea.field{padding:8px 10px}:root.xt-mobile #qr.qr-mobile .persona input.field,:root.xt-mobile #qr.qr-mobile #qr-filename-container{padding-left:10px;padding-right:10px}:root.xt-mobile #qr.qr-mobile #qr-file-button,:root.xt-mobile #qr.qr-mobile #qr-file [type="submit"]{border:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:8px;background-color:rgba(128,128,128,.12);font-size:15px;font-weight:600;appearance:none}:root.xt-mobile #qr.qr-mobile #qr-file [type="submit"]{background-color:rgba(128,128,128,.25)}:root.xt-mobile #qr.qr-mobile .qr-mobile-handle{opacity:.6}:root.xt-mobile #qr.qr-mobile .qr-mobile-overflow{display:none;position:absolute;right:0;bottom:calc(100% + 6px);min-width:230px;max-height:60vh;overflow-y:auto;overscroll-behavior:contain;z-index:5;background-color:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));border:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.4)}:root.xt-mobile #qr.qr-mobile.qr-overflow-open .qr-mobile-overflow{display:block}:root.xt-mobile #qr.qr-mobile .qr-overflow-row{display:flex;align-items:center;gap:10px;min-height:44px;padding:0 14px}:root.xt-mobile #qr.qr-mobile .qr-overflow-row:has(>a[hidden]){display:none}:root.xt-mobile #qr.qr-mobile .qr-overflow-row>a{display:inline-flex;align-items:center;justify-content:center;width:28px;flex:0 0 28px;font-size:16px}:root.xt-mobile #qr.qr-mobile .qr-overflow-label{flex:1;cursor:pointer}:root.xt-mobile #qr.qr-mobile #file-n-submit #qr-actions{display:inline-flex}:root.xt-mobile .qr-mobile-overflow:not(.has-file) .qr-overflow-row:has(>:is(#qr-randomize,#qr-restore-name)),:root.xt-mobile .qr-mobile-overflow:not(.has-image) .qr-overflow-row:has(>#qr-jpg),:root.xt-mobile .qr-mobile-overflow:not(.has-video) .qr-overflow-row:has(>#qr-dogiri-button),:root.xt-mobile .qr-mobile-overflow.has-video .qr-overflow-row:has(>#qr-oekaki-button),:root.xt-mobile .qr-mobile-overflow:not(.has-image):not(.has-video) .qr-overflow-row:has(>#qr-view),:root.xt-mobile .qr-mobile-overflow.has-file .qr-overflow-row:has(>:is(#paste-area,#url-button)),:root.xt-mobile .qr-mobile-overflow:not(.custom-cooldown) .qr-overflow-row:has(>#custom-cooldown-button){display:none}:root.xt-mobile #qr.qr-mobile>.move #qr-preview-toggle,:root.xt-mobile .qr-preview-float,:root.xt-mobile #thread-watcher>.move>.attach{display:none}:root.xt-mobile #qr .captcha-root,:root.xt-mobile #qr .captcha-root>div,:root.xt-mobile #qr #t-root{box-shadow:none !important}:root.xt-mobile #qr #t-load{min-height:44px;min-width:120px;padding:10px 18px;font-size:15px;border-radius:8px}:root.xt-mobile #thread-watcher>.move>.close{margin-left:auto}:root.xt-mobile #qr.qr-mobile .flagSelector-toggle{min-height:44px;padding:8px 30px 8px 10px;border-radius:8px}:root.xt-mobile .flagSelector-menu{position:fixed !important;left:8px !important;right:8px;width:auto !important;top:auto !important;bottom:calc(8px + max(env(safe-area-inset-bottom,0px),var(--xt-bar-bottom-inset,0px))) !important;max-height:55vh !important;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.4)}:root.xt-mobile .flagSelector-option{min-height:44px;padding:10px 14px;gap:10px;font-size:15px}:root.xt-mobile #board-list{display:none}:root.xt-mobile:not(.fixed) #header-bar{position:relative}#boardpicker{display:none}:root.xt-mobile.xt-boardpicker-open #boardpicker{display:flex;flex-direction:column;position:absolute;top:100%;left:0;right:0;z-index:30;box-sizing:border-box;max-height:70vh;background-color:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));border-bottom:1px solid var(--xt-border,rgba(128,128,128,.45));box-shadow:0 12px 24px rgba(0,0,0,.4)}.boardpicker-toggle{text-decoration:none;white-space:nowrap}.boardpicker-tabs{display:flex;flex:0 0 auto;gap:6px;padding:8px 10px 4px}.boardpicker-tab{flex:1 1 0;text-align:center;padding:10px 8px;border:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:6px;text-decoration:none}.boardpicker-tab.active{background-color:rgba(128,128,128,.25);font-weight:700}.boardpicker-search{display:block;flex:0 0 auto;box-sizing:border-box;width:calc(100% - 20px);margin:4px 10px;min-height:44px}.boardpicker-search[hidden]{display:none}.boardpicker-list{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding-bottom:6px}.boardpicker-row{display:flex;align-items:center;min-height:44px}.boardpicker-row.current{background-color:rgba(128,128,128,.2)}.boardpicker-star{flex:0 0 44px;align-self:stretch;display:flex;align-items:center;justify-content:center;font-size:18px;text-decoration:none}.boardpicker-link{flex:1 1 auto;min-width:0;padding:12px 10px 12px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.boardpicker-empty{padding:14px 12px;opacity:.7}:root.xt-mobile #shortcut-boards{margin-right:auto}:root.xt-mobile #shortcut-boards .icon{font-size:21px}:root.xt-mobile #header-bar{transition:transform .18s ease-out}:root.xt-mobile #header-bar.autohide.scroll{transform:translateY(-100%);box-shadow:none;transition:transform .18s ease-in}:root.xt-mobile __xt-site-sideArrows,:root.xt-mobile .replacedSideArrows,:root.xt-mobile .postInfo input[type="checkbox"][value="delete"],:root.xt-mobile .postInfo .menu-button{display:none}:root.xt-mobile #shortcut-stats,:root.xt-mobile #shortcut-updater,:root.xt-mobile #shortcut-prefetch,:root.xt-mobile #shortcut-expand-all,:root.xt-mobile #shortcut-download-all,:root.xt-mobile #blotter,:root.xt-mobile .boardTitle,:root.xt-mobile.thread-view .qr-link-container,:root.xt-mobile.thread-view #togglePostFormLink,:root.xt-mobile.thread-view __xt-site-psa,:root.xt-mobile.thread-view .navLinks:not(.navLinksBot):not(.json-index){display:none}:root.xt-mobile .navLinks.navLinksBot,:root.xt-mobile .navLinksBot + hr.desktop,:root.xt-mobile.thread-view .board>hr,:root.xt-mobile .danbo-slot,:root.xt-mobile .adl,:root.xt-mobile .adl + hr,:root.xt-mobile hr.aboveMidAd,:root.xt-mobile .bottomCtrl{display:none}:root.xt-mobile __xt-site-postContainer{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}#menu-backdrop{display:none}:root.xt-mobile #menu-backdrop{display:block;position:fixed;top:0;left:0;right:0;bottom:0;z-index:49;background-color:rgba(0,0,0,.45);opacity:1;transition:opacity .12s ease,display .12s allow-discrete}@starting-style{:root.xt-mobile #menu-backdrop:not(.menu-hidden){opacity:0}}:root.xt-mobile #menu-backdrop.menu-hidden{display:none;opacity:0;pointer-events:none}:root.xt-mobile #menu{top:auto;left:0;right:0;bottom:var(--xt-bar-bottom-inset,0px);width:100%;min-width:0;max-width:none;max-height:75vh;overflow-y:auto;overscroll-behavior:contain;box-sizing:border-box;border-radius:12px 12px 0 0;padding-top:6px;padding-bottom:calc(8px + env(safe-area-inset-bottom))}:root.xt-mobile #menu.menu-anchor-header{top:var(--xt-mobile-header-h,45px);bottom:auto;max-height:calc(100dvh - var(--xt-mobile-header-h,45px) - var(--xt-mobile-bar-h));border-radius:0 0 12px 12px;padding-bottom:8px;border-bottom:1px solid var(--xt-border,rgba(128,128,128,.45));box-shadow:0 12px 24px rgba(0,0,0,.4)}:root.xt-mobile.xt-icons-top #menu.menu-anchor-header{top:auto;bottom:calc(var(--xt-mobile-header-h,45px) + env(safe-area-inset-bottom,0px));border-radius:12px 12px 0 0;border-bottom:none;border-top:1px solid var(--xt-border,rgba(128,128,128,.45));box-shadow:0 -12px 24px rgba(0,0,0,.4)}:root.xt-mobile #menu-backdrop.menu-anchor-header{background-color:transparent}:root.xt-mobile #menu:not(.menu-hidden){transform:none}@starting-style{:root.xt-mobile #menu:not(.menu-hidden){opacity:0;transform:translateY(24px)}:root.xt-mobile #menu.menu-anchor-header:not(.menu-hidden){transform:translateY(-24px)}:root.xt-mobile.xt-icons-top #menu.menu-anchor-header:not(.menu-hidden){transform:translateY(24px)}:root.xt-mobile #menu.menu-anchor-point:not(.menu-hidden){transform:scale(.97)}}:root.xt-mobile #menu.menu-hidden{transform:translateY(24px)}:root.xt-mobile #menu.menu-anchor-header.menu-hidden{transform:translateY(-24px)}:root.xt-mobile.xt-icons-top #menu.menu-anchor-header.menu-hidden{transform:translateY(24px)}:root.xt-mobile #menu.menu-anchor-point{top:auto;left:auto;right:auto;bottom:auto;width:auto;min-width:180px;max-width:min(280px,calc(100vw - 16px));max-height:60vh;border-radius:12px;padding-bottom:4px;transform-origin:top left;box-shadow:0 8px 24px rgba(0,0,0,.4)}:root.xt-mobile #menu.menu-anchor-point .entry{padding:10px 14px;font-size:15px}:root.xt-mobile #menu .entry .shortcut-text{display:none}:root.xt-mobile #menu.menu-anchor-point.menu-hidden{transform:scale(.97)}:root.xt-mobile #menu-backdrop.menu-anchor-point{background-color:transparent}:root.xt-mobile #menu .entry{padding:13px 16px;font-size:16px;line-height:1.25}:root.xt-mobile #fourchanx-settings.settings-nav-collapsed>.settings-body>nav{position:absolute;top:0;left:0;right:0;bottom:auto;width:100%;max-width:none;max-height:85%;margin:0;padding:10px 12px;border:none;border-bottom:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:0 0 12px 12px;box-shadow:0 8px 24px rgba(0,0,0,.35)}:root.xt-mobile #fourchanx-settings.settings-nav-collapsed.settings-nav-open>.settings-body::before{content:"";position:absolute;top:0;left:0;right:0;bottom:0;z-index:3;background-color:rgba(0,0,0,.45)}:root.xt-mobile #fourchanx-settings.settings-nav-collapsed.settings-nav-open>.settings-body>nav{transition:opacity .12s ease,transform .12s ease}@starting-style{:root.xt-mobile #fourchanx-settings.settings-nav-collapsed.settings-nav-open>.settings-body>nav{opacity:0;transform:translateY(-24px)}}:root.xt-mobile #fourchanx-settings.settings-nav-collapsed>.settings-body>nav .sections-list{max-height:none}:root.xt-mobile .sections-list>a{padding:11px 12px;font-size:15px;border-radius:6px}:root.xt-mobile #fourchanx-settings{inset:0;width:100%;height:100%;min-width:0;min-height:0;max-width:none;max-height:none;margin:0;border:none;border-radius:0;box-shadow:none;resize:none}:root.xt-mobile .settings-titlebar{cursor:default;padding:8px 10px}:root.xt-mobile .settings-titlebar-actions>a.accordion-toggle,:root.xt-mobile .settings-titlebar-actions>a.settings-nav-toggle,:root.xt-mobile .settings-titlebar-actions>a.expand-all,:root.xt-mobile .settings-titlebar-actions>a.collapse-all{padding:8px;font-size:1.15em}:root.xt-mobile #xt-settings-overlay .close{padding:8px 10px;font-size:1.15em}:root.xt-mobile .settings-footer{font-size:12px;padding:8px 10px calc(8px + env(safe-area-inset-bottom))}:root.xt-mobile .settings-footer-menu-toggle{width:34px;height:34px}:root.xt-mobile #menu .entry input[type="checkbox"],:root.xt-mobile #menu .entry input[type="radio"]{width:19px;height:19px;top:4px;margin-right:8px}:root.xt-mobile #menu .entry.has-submenu{flex-wrap:wrap}:root.xt-mobile #menu .submenu{position:static;display:none;flex-basis:100%;margin:4px 0 2px 14px;border-radius:6px}:root.xt-mobile #menu .entry.submenu-open>.submenu{display:block}:root.xt-mobile #menu .entry.has-submenu>.menu-indicator>.icon{transform:none;transition:transform .12s ease}:root.xt-mobile #menu .entry.has-submenu.submenu-open>.menu-indicator>.icon{transform:rotate(90deg)}.mobile-replies-chip{display:none}:root.xt-replies-chip .postInfo .container,:root.xt-replies-chip .post>.container{display:none}:root.xt-mobile .postMessage{overflow-wrap:anywhere}:root.xt-mobile .expanded-image .fileThumb{position:relative;display:inline-block}:root.xt-mobile .xt-video-contract{position:absolute;top:6px;right:6px;display:flex;align-items:center;justify-content:center;width:40px;height:40px;background:rgba(0,0,0,.55);color:#fff !important;border-radius:8px;font-size:17px;z-index:2;text-decoration:none}:root.xt-mobile .postMessage pre.prettyprint{overflow-x:auto;max-width:calc(100vw - 110px)}:root.xt-mobile .fileText .file-info>a[target="_blank"],:root.xt-mobile .fileText .file-info .download-button,:root.xt-mobile .fileText .sauce-container{display:none}:root.xt-mobile .postContainer:not(.qr-preview-post) .fileText{display:none}:root.xt-mobile #menu .filename-link{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:root.xt-replies-chip .mobile-replies-chip{display:inline-flex;align-items:center;gap:4px;position:absolute;right:8px;bottom:5px;padding:1px 8px;border-radius:999px;background:rgba(128,128,128,.18);text-decoration:none;font-size:12px}:root.xt-replies-chip .post:has(>.postInfo .mobile-replies-chip:not([hidden])){position:relative;padding-bottom:26px}:root.xt-mobile .thread .post.reply,:root.xt-mobile .mobile-replies-item .post{width:100%;box-sizing:border-box}:root.xt-mobile .postInfo .watch-thread-link{display:none}:root.xt-mobile #shortcut-watch-thread .watch-thread-shortcut{opacity:.45}:root.xt-mobile #shortcut-watch-thread .watch-thread-shortcut.watched{opacity:1}:root.xt-mobile __xt-site-postContainer.xt-press{filter:brightness(.82);transition:filter .2s ease .1s}:root.xt-mobile __xt-site-postContainer.xt-menu-target>.post{box-shadow:0 0 0 2px var(--xt-link,#57a),0 0 12px color-mix(in srgb,var(--xt-link,#57a) 55%,transparent);border-radius:4px;transition:box-shadow .15s ease}:root.xt-mobile #menu{z-index:70}:root.xt-mobile #menu .entry.focused{background:none}:root.xt-mobile #menu-backdrop{z-index:69}:root.xt-mobile #select-text-backdrop{position:fixed;inset:0;z-index:62;background:rgba(0,0,0,.45)}:root.xt-mobile #select-text{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:63;display:flex;flex-direction:column;box-sizing:border-box;width:min(94vw,500px);max-height:min(72dvh,calc(100dvh - var(--xt-mobile-header-h,45px) - var(--xt-mobile-bar-h) - 24px));border:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:12px}:root.xt-mobile .select-text-header{display:flex;align-items:center;justify-content:space-between;font-weight:bold;padding:8px 6px 4px 12px}:root.xt-mobile .select-text-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:root.xt-mobile .select-text-close{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;font-size:16px}:root.xt-mobile .select-text-body{overflow-y:auto;overscroll-behavior:contain;padding:0 12px 10px;user-select:text;-webkit-user-select:text;-webkit-touch-callout:default}:root.xt-mobile .select-text-body blockquote{margin:0}:root.xt-mobile .select-text-actions{display:flex;gap:8px;padding:8px 12px calc(8px + env(safe-area-inset-bottom,0px))}:root.xt-mobile .select-text-actions>a{flex:1;display:flex;align-items:center;justify-content:center;min-height:44px;border:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:8px;background:rgba(128,128,128,.12);text-decoration:none}:root.xt-replies-chip .mobile-replies-chip[hidden]{display:none}:root.xt-mobile #fourchanx-settings div[data-name="Thread Watcher Attach Controls"],:root.xt-mobile #fourchanx-settings div[data-name="Comment Preview Attach Location"]{display:none}:root.xt-mobile #mobile-replies-backdrop{position:fixed;inset:0;z-index:60;background:rgba(0,0,0,.45)}:root.xt-mobile #mobile-replies{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:61;display:flex;flex-direction:column;box-sizing:border-box;width:min(94vw,500px);max-height:min(72dvh,calc(100dvh - var(--xt-mobile-header-h,45px) - var(--xt-mobile-bar-h) - 24px));border:1px solid var(--xt-border,rgba(128,128,128,.45));border-radius:12px;background:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));box-shadow:0 12px 24px rgba(0,0,0,.4);overflow:hidden}:root.xt-mobile .mobile-replies-header{display:flex;align-items:center;justify-content:space-between;font-weight:bold;padding:8px 6px 4px}:root.xt-mobile .mobile-replies-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:root.xt-mobile .mobile-replies-back,:root.xt-mobile .mobile-replies-close{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;font-size:16px}:root.xt-mobile .mobile-replies-list{overflow-y:auto;overscroll-behavior:contain;padding:0 10px 10px}:root.xt-mobile .mobile-replies-item{margin-bottom:8px}:root.xt-mobile .mobile-replies-item .post{border:none;margin:0;padding:2px 2px 5px;max-width:100%}:root.xt-mobile .mobile-replies-item .postContainer::after{content:'';clear:both;display:table}:root.xt-mobile .mobile-replies-item img{max-height:40dvh;max-width:45vw}#mobile-bar{display:none}:root.xt-mobile{--xt-mobile-bar-h:48px;--xt-mobile-header-h:45px;--xt-bar-bottom-inset:0px;--xt-bar-top-inset:0px}:root.xt-mobile.xt-bar-split.xt-icons-bottom{--xt-bar-bottom-inset:calc(var(--xt-mobile-bar-h) + env(safe-area-inset-bottom,0px))}:root.xt-mobile.xt-bar-split.xt-icons-top{--xt-bar-top-inset:var(--xt-mobile-bar-h)}:root.xt-mobile #xt-settings-overlay{top:var(--xt-bar-top-inset,0px);bottom:var(--xt-bar-bottom-inset,0px);height:auto}:root.xt-mobile.xt-bar-split #mobile-bar{display:flex;position:fixed;left:0;right:0;height:var(--xt-mobile-bar-h);z-index:8;justify-content:space-evenly;align-items:center;gap:4px;padding:4px 8px;box-sizing:border-box;background-color:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)))}:root.xt-mobile.xt-bar-split #header-bar{background-color:var(--xt-header-dialog-bg,var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas))))}:root.xt-mobile.xt-bar-split.xt-icons-bottom #mobile-bar{bottom:env(safe-area-inset-bottom,0px);padding-bottom:4px;border-top:1px solid var(--xt-border,rgba(128,128,128,.45));box-shadow:0 -1px 2px rgba(0,0,0,.15);transition:bottom .18s ease-out}:root.xt-mobile.xt-bar-split.xt-icons-bottom #mobile-bar::after{content:'';position:absolute;top:100%;left:0;right:0;height:env(safe-area-inset-bottom,0px);background:inherit;transition:height .18s ease-out}:root.xt-mobile.xt-bar-split.xt-icons-top #mobile-bar{top:0;border-bottom:1px solid var(--xt-border,rgba(128,128,128,.45));box-shadow:0 1px 2px rgba(0,0,0,.15)}:root.xt-mobile.xt-bar-split.xt-icons-top #header-bar{position:fixed;top:auto;bottom:env(safe-area-inset-bottom,0px);left:0;right:0;padding-bottom:6px;border-top:1px solid var(--xt-border,rgba(128,128,128,.45));box-shadow:0 -1px 2px rgba(0,0,0,.15);transition:transform .18s ease-out,bottom .18s ease-out}:root.xt-mobile.xt-bar-split.xt-icons-top #header-bar::after{content:'';position:absolute;top:100%;left:0;right:0;height:env(safe-area-inset-bottom,0px);background:inherit;transition:height .18s ease-out}:root.xt-mobile.xt-bar-split.xt-icons-bottom body{padding-top:var(--xt-mobile-header-h,45px);padding-bottom:calc(var(--xt-mobile-bar-h) + env(safe-area-inset-bottom))}:root.xt-mobile.xt-bar-split.xt-icons-top body{padding-top:var(--xt-mobile-bar-h);padding-bottom:calc(var(--xt-mobile-bar-h) + env(safe-area-inset-bottom))}:root.xt-mobile.xt-boardpicker-open.xt-bar-split.xt-icons-top #boardpicker{top:auto;bottom:100%;border-bottom:none;border-top:1px solid var(--xt-border,rgba(128,128,128,.45));box-shadow:0 -12px 24px rgba(0,0,0,.4)}:root.xt-mobile.xt-bar-split.xt-icons-top #header-bar.autohide.scroll{transform:translateY(calc(100% + env(safe-area-inset-bottom,0px)));box-shadow:none}:root.xt-mobile #navlinks{top:auto;right:10px;bottom:calc(var(--xt-mobile-bar-h) + 10px + env(safe-area-inset-bottom,0px));display:flex;flex-direction:column;border-radius:999px;overflow:hidden;background:var(--xt-dialog-bg,var(--xt-background,var(--background,Canvas)));border:1px solid var(--xt-border,rgba(128,128,128,.45));box-shadow:0 2px 8px rgba(0,0,0,.3);opacity:.55;touch-action:pan-y;transition:transform .2s ease,opacity .2s ease}:root.xt-mobile #navlinks:active{opacity:1}:root.xt-mobile #navlinks.navlinks-docked{transform:translateX(calc(100% - 6px));opacity:.4}:root.xt-mobile #navlinks.navlinks-docked:active{opacity:.4}:root.xt-mobile #navlinks .navlinks-navlink{display:flex;align-items:center;justify-content:center;width:44px;height:44px;font-size:20px}:root.xt-mobile #navlinks .navlinks-navlink + .navlinks-navlink{border-top:1px solid var(--xt-border,rgba(128,128,128,.45))}:root.xt-mobile.gallery-open #navlinks,:root.xt-mobile.catalog-mode #navlinks,:root.xt-mobile.xt-qr-open #navlinks{display:none}`; var tomorrow = `:root.tomorrow{--xt-background:#282A2E;--xt-border:#111;--xt-border-field-focus:rgb(129,162,190);--xt-border-highlight:rgba(145,182,214,.8);--xt-header-dialog-bg:rgba(40,42,46,0.9);--xt-header-dialog-fg:#C5C8C6;--xt-header-link:#81A2BE;--xt-quote-color:#B5BD68;--xt-dead-link:#81A2BE;--xt-inline:rgba(0,0,0,.14);--xt-qr-preview-bg:rgba(255,255,255,.15);--xt-qr-link-border:rgb(25,27,31) rgb(25,27,31) rgb(10,12,16);--xt-qr-bg:linear-gradient(#37393D,#282A2E) repeat scroll 0% 0% transparent;--xt-menu-fg:#C5C8C6;--xt-entry-focus-bg:rgba(0,0,0,.33);--xt-unread:rgba(40,42,46,0.5);--xt-watcher:#c5c8c6;--xt-unread-line:rgb(197,200,198);--xt-filter-highlight:rgba(145,182,214,.5);--xt-highlight-shadow:rgba(64,192,255,.7);--xt-watched-border:rgb(64,192,255);--xt-qphl:rgba(145,182,214,.8);--xt-highlight-side-arrow:rgb(155,185,210);--xt-fxt-fg:#c5c8c6}:root.tomorrow #arc-list span.quote{color:#B5BD68}:root.tomorrow #header-bar a,:root.tomorrow #notifications a{color:#81A2BE}:root.tomorrow.shortcut-icons .native-settings{background-image:url('//s.4cdn.org/image/favicon-ws.ico')}:root.tomorrow #qr .field{background-color:rgb(26,27,29);color:rgb(197,200,198);border-color:rgb(40,41,42)}:root.tomorrow #qr .field:focus,:root.tomorrow #qr .field.focus{border-color:var(--xt-border-field-focus) !important;background-color:rgb(30,32,36)}:root.tomorrow #qr select{border-color:rgb(40,41,42);background:unset}:root.tomorrow #qr select>option{background-color:var(--xt-background)}:root.tomorrow #qr-filename{color:rgb(197,200,198)}:root.tomorrow #download-all-picker .da-buttons button{background-color:rgb(26,27,29);color:rgb(197,200,198);border-color:rgb(40,41,42)}:root.tomorrow #download-all-picker .da-buttons button:hover:not(:disabled){background-color:rgb(30,32,36);border-color:var(--xt-border-field-focus)}`; var www = `#captcha-cnt{height:auto}:root:not(.js-enabled) #form{display:block}#bd>div[style],#bd>div[style]>*{height:auto !important;margin:0 !important;font-size:0}:root.tomorrow body,:root.tomorrow #bd,:root.tomorrow #ft,:root.tomorrow .box-content{background-color:#1d1f21 !important;background-image:none !important;color:#c5c8c6 !important;border-color:#282a2e !important}:root.tomorrow .boxbar,:root.tomorrow #nav-top,:root.tomorrow #header{background-color:#282a2e !important;background-image:none !important;color:#c5c8c6 !important}:root.tomorrow a{color:#81a2be !important}:root.spooky body,:root.spooky #bd,:root.spooky #ft,:root.spooky .box-content{background-color:#2e2e2e !important;background-image:none !important;color:#ccc !important;border-color:#111 !important}:root.spooky .boxbar,:root.spooky #nav-top,:root.spooky #header{background-color:#111 !important;background-image:none !important;color:#ccc !important}:root.spooky a{color:#f60 !important}:root.yotsuba body,:root.yotsuba #bd,:root.yotsuba #ft,:root.yotsuba .box-content{background-color:#ffefe5 !important;background-image:none !important;color:#800000 !important;border-color:#d9bfb7 !important}:root.yotsuba .boxbar,:root.yotsuba #nav-top,:root.yotsuba #header{background-color:#f0e0d6 !important;background-image:none !important;color:#800000 !important}:root.yotsuba-b body,:root.yotsuba-b #bd,:root.yotsuba-b #ft,:root.yotsuba-b .box-content{background-color:#d6daf0 !important;background-image:none !important;color:#000 !important;border-color:#b7c5d9 !important}:root.yotsuba-b .boxbar,:root.yotsuba-b #nav-top,:root.yotsuba-b #header{background-color:#adc2ff !important;background-image:none !important;color:#000 !important}:root.yotsuba-b a{color:#34345c !important}:root.photon body,:root.photon #bd,:root.photon #ft,:root.photon .box-content{background-color:#dee5f7 !important;background-image:none !important;color:#000 !important;border-color:#b7c5d9 !important}:root.photon .boxbar,:root.photon #nav-top,:root.photon #header{background-color:#adc2ff !important;background-image:none !important;color:#000 !important}:root.photon a{color:#004a99 !important}:root.futaba body,:root.futaba #bd,:root.futaba #ft,:root.futaba .box-content{background-color:#f0e0d6 !important;background-image:none !important;color:#800000 !important;border-color:#d9bfb7 !important}:root.futaba .boxbar,:root.futaba #nav-top,:root.futaba #header{background-color:#ffefe5 !important;background-image:none !important;color:#800000 !important}:root.burichan body,:root.burichan #bd,:root.burichan #ft,:root.burichan .box-content{background-color:#eceff6 !important;background-image:none !important;color:#000 !important;border-color:#d9bfb7 !important}:root.burichan .boxbar,:root.burichan #nav-top,:root.burichan #header{background-color:#d6daf0 !important;background-image:none !important;color:#000 !important}:root.burichan a{color:#34345c !important}`; var yotsubaB = `:root.yotsuba-b{--xt-background:#D6DAF0;--xt-link-hover-bg:#D9DDF3;--xt-border:#B7C5D9;--xt-border-field-focus:#98E;--xt-border-highlight:rgba(221,0,0,.8);--xt-filter-highlight:rgba(221,0,0,.5);--xt-highlight-shadow:rgba(221,0,0,.5);--xt-highlight-side-arrow:rgba(221,0,0,.8);--xt-watched-border:rgb(221,0,0);--xt-header-dialog-bg:rgba(214,218,240,0.98);--xt-header-dialog-fg:#89A;--xt-header-link:#34345C;--xt-quote-color:#789922;--xt-dead-link:#34345C;--xt-qr-link-border:rgb(199,203,225) rgb(199,203,225) rgb(184,188,210);--xt-qr-bg:linear-gradient(#E5E9FF,#D6DAF0) repeat scroll 0% 0% transparent;--xt-menu-fg:#000;--xt-entry-focus-bg:rgba(255,255,255,.33);--xt-unread:rgba(214,218,240,0.5)}`; var yotsuba = `:root.yotsuba{--xt-background:#F0E0D6;--xt-border:#D9BFB7;--xt-border-field-focus:#EA8;--xt-border-highlight:rgba(221,0,0,.8);--xt-filter-highlight:rgba(221,0,0,.5);--xt-highlight-shadow:rgba(221,0,0,.5);--xt-highlight-side-arrow:rgba(221,0,0,.8);--xt-watched-border:rgb(221,0,0);--xt-header-dialog-bg:rgba(240,224,214,0.98);--xt-header-dialog-fg:#B86;--xt-header-link:#800000;--xt-quote-color:#789922;--xt-dead-link:#00E;--xt-qr-link-border:rgb(225,209,199) rgb(225,209,199) rgb(210,194,184);--xt-qr-bg:linear-gradient(#FFEFE5,#F0E0D6) repeat scroll 0% 0% transparent;--xt-entry-focus-bg:rgba(255,255,255,.33);--xt-unread:rgba(240,224,214,0.5);--xt-watcher:#800000;--xt-fxt-fg:#800000}`; const icons = (data) => ('/* Link Title Favicons */\n' + data.map(({ name, data }) => `.linkify.${name}::before { content: ""; background: transparent url('data:image/png;base64,${data}') center left no-repeat!important; padding-left: 18px; } `).join('')); var iconCss = `svg.icon{height:1em;width:1em;display:inline-flex;vertical-align:-.125em}:root.catalog svg.icon{position:static}:root.shortcut-icons #shortcuts .icon--alt-text,:root.shortcut-icons #mobile-bar .icon--alt-text,:root:not(.shortcut-icons) .shortcut .icon{display:none}:root.shortcut-icons .shortcut.brackets-wrap::before,:root.shortcut-icons .shortcut.brackets-wrap::after{display:none}@keyframes spin{100%{transform:rotate(1turn)}}`; var fxTwitterCss = `@layer custom-property-declarations{:root{--xt-background:initial;--xt-border:initial;--xt-dead-link:initial;--xt-fxt-fg:initial}}:root{--xt-fxt-bg:var(--xt-background);--xt-fxt-border:var(--xt-border)}.fxt-card{color:var(--xt-fxt-fg,#000);background-color:var(--xt-fxt-bg,#D6DAF0);padding:16px;border:1px solid var(--xt-fxt-border,#B7C5D9);border-radius:12px;gap:8px;width:550px;display:grid;grid-template-columns:48px minmax(0,1fr);grid-template-rows:max-content max-content max-content;grid-template-areas:"meta meta" "content content" "stats stats"}.fxt-meta{display:flex;flex-direction:row;gap:8px;grid-area:meta}.fxt-meta_profile{display:flex;flex-direction:row;align-items:center;gap:8px}.fxt-meta_avatar{display:flex;align-items:center}.fxt-meta_profile img{height:48px;width:48px;aspect-ratio:1;border-radius:100%;background-color:var(--xt-fxt-border,#B7C5D9)}.fxt-meta_author{display:flex;flex-direction:column;flex-wrap:nowrap}.fxt-meta_author_username{font-weight:bold}.fxt-stats_meta{padding-top:8px;display:flex;flex-direction:row;justify-content:space-evenly;gap:8px}.fxt-stats_meta span{display:inline-flex;align-items:center;gap:4px}.fxt-content{flex:1;display:flex;flex-direction:column;gap:.5rem;grid-area:content}.fxt-text{white-space:pre-wrap;word-break:break-word}.fxt-poll{display:flex;flex-direction:column;gap:4px}.fxt-choice{position:relative;display:flex;align-items:center;justify-content:space-between;font-size:75%;height:24px}.fxt-choice.highlight{font-weight:bold}.fxt-choice_label{margin-left:6px;z-index:1}.fxt-choice_percentage{margin-right:6px;z-index:1}.fxt-choice .fxt-bar{position:absolute;top:0;left:0;height:24px;border-radius:4px;background-color:currentColor;opacity:.3;z-index:0}.fxt-total-votes{font-size:75%;opacity:.5}.fxt-media{width:100%;height:100%;border-radius:4px;overflow:hidden}.fxt-media_container{--maxHeightMedia:300px}.fxt-media_container :is(img,video){display:block;width:100%;height:100%;max-height:var(--maxHeightMedia);max-width:none;object-fit:contain;object-position:center}.fxt-media_container{display:grid;gap:4px;grid-template-columns:repeat(2,1fr);grid-auto-rows:auto;grid-auto-flow:dense;aspect-ratio:16 / 9}.fxt-media_container .fxt-media:only-child{grid-column:span 2 / auto;grid-row:span 2 / auto}.fxt-media_contains_3 .fxt-media:first-child{grid-row:span 2 / auto}.fxt-media_contains_3 .fxt-media :is(img,video){max-height:calc(var(--maxHeightMedia) / 2)}.fxt-media_contains_3 .fxt-media:first-child :is(img,video){max-height:calc(var(--maxHeightMedia) + 4px)}.fxt-media_video{background-color:#000}.fxt-tweet-quote{width:fit-content;padding:8px}.fxt-tweet-quote .fxt-meta_profile img{height:24px;width:24px}.fxt-tweet-quote .fxt-meta_author{font-size:80%}.fxt-tweet-quote .fxt-meta{display:flex;flex-direction:row;align-items:center;gap:8px;padding:4px}.fxt-community_note{display:flex;flex-direction:column;border:1px solid var(--xt-fxt-border,#B7C5D9);border-radius:.25rem}.fxt-community_note-header{display:flex;align-items:center;gap:.5rem;padding:.25rem .5rem;background-color:var(--xt-fxt-border,#B7C5D9)}.fxt-community_note-text{padding:.5rem;white-space:pre-wrap}.fxt-tweet-reply{grid-template-areas:"meta meta" "line content" "line stats"}.fxt-tweet-reply::before{grid-area:line;content:'';display:block;background-color:var(--xt-fxt-border,#B7C5D9);height:calc(100% + 18px);width:5px;margin:auto;position:relative;top:-5px}.fxt-stats{grid-area:stats}.fxt-reply_container>.fxt-tweet-reply,.fxt-reply_container+.fxt-tweet-original{border:0;border-radius:0;padding-top:8px;padding-bottom:8px}.fxt-reply_container>.fxt-tweet-reply:first-child{border-top-left-radius:12px;border-top-right-radius:12px;border:1px solid var(--xt-fxt-border,#B7C5D9);border-bottom:0}.fxt-reply_container>.fxt-tweet-reply:not(:first-child){border:1px solid var(--xt-fxt-border,#B7C5D9);border-top:0;border-bottom:0}.fxt-reply_container+.fxt-tweet-original{border-bottom-left-radius:12px;border-bottom-right-radius:12px;border:1px solid var(--xt-fxt-border,#B7C5D9);border-top:0}.fxt-text_translated-label,.fxt-stats{color:var(--xt-dead-link)}.fxt-card:is(.warning,.loading){display:flex}.fxt-card_container{max-block-size:90dvh;overflow:hidden auto;scrollbar-width:thin;width:fit-content}`; const mainCSS = style + variableBase + yotsuba + yotsubaB + futaba + burichan + tomorrow + photon + spooky + iconCss + fxTwitterCss; const faIcons = [ { name: "audio", data: linkifyAudio }, { name: "bitchute", data: linkifyBitchute }, { name: "clyp", data: linkifyClyp }, { name: "dailymotion", data: linkifyDailymotion }, { name: "gfycat", data: linkifyGfycat }, { name: "gist", data: linkifyGist }, { name: "image", data: linkifyImage }, { name: "installgentoo", data: linkifyInstallgentoo }, { name: "liveleak", data: linkifyLiveleak }, { name: "pastebin", data: linkifyPastebin }, { name: "peertube", data: linkifyPeertube }, { name: "soundcloud", data: linkifySoundcloud }, { name: "streamable", data: linkifyStreamable }, { name: "twitchtv", data: linkifyTwitchtv }, { name: "twitter", data: linkifyX }, { name: "video", data: linkifyVideo }, { name: "vidlii", data: linkifyVidlii }, { name: "vimeo", data: linkifyVimeo }, { name: "vine", data: linkifyVine }, { name: "vocaroo", data: linkifyVocaroo }, { name: "youtube", data: linkifyYoutube }, ]; const CSS = { boards: mainCSS + icons(faIcons), report, www, sub: function (css) { var variables = { site: g.SITE?.selectors || {} }; return css.replace(/\$[\w\$]+|__xt-[\w-]+/g, function (name) { var words = name[0] === '$' ? name.slice(1).split('$') : name.slice('__xt-'.length).split('-'); var sel = variables; for (var i = 0; i < words.length; i++) { if (typeof sel !== 'object') return ':not(*)'; sel = $.getOwn(sel, words[i]); } if (typeof sel !== 'string') return ':not(*)'; return sel; }); } }; const CustomCSS = { style: null, init() { if (!Settings.stylingSectionEnabled('customCSS')) { return; } if (!Conf['Custom CSS']) { return; } return this.addStyle(); }, currentCSS() { return Settings.styleConf('usercss') || ''; }, addStyle() { return this.style = $.addStyle(CSS.sub(this.currentCSS()), 'custom-css', '#fourchanx-css'); }, rmStyle() { if (this.style) { $.rm(this.style); return delete this.style; } }, update() { if (!Settings.stylingSectionEnabled('customCSS') || !Conf['Custom CSS']) { return this.rmStyle(); } if (!this.style) { return this.addStyle(); } return this.style.textContent = CSS.sub(this.currentCSS()); } }; const passMessagePage = h("div", { class: "box-inner" }, h("div", { class: "boxbar" }, h("h2", null, "Trouble buying a 4chan Pass? (a message from 4chan X)", h("a", { href: "javascript:;", style: "text-decoration: none; float: right; margin-right: 4px;", title: "Close" }, "\u00D7"))), h("div", { class: "boxcontent" }, "Check the 4chan X wiki for ", h("a", { href: `${meta.captchaFAQ}#alternatives`, target: "_blank", rel: "noopener" }, "alternative solutions"), ".")); const PassMessage = { init() { if (Conf['passMessageClosed']) { return; } const msg = $.el('div', { className: 'box-outer top-box' }, passMessagePage); msg.style.cssText = 'padding-bottom: 0;'; const close = $('a', msg); $.on(close, 'click', function () { $.rm(msg); return $.set('passMessageClosed', true); }); return $.ready(function () { let hd; if (hd = $.id('hd')) { return $.after(hd, msg); } else { return $.prepend(d.body, msg); } }); } }; var ReportPage = ` `; var Report = { postID: null, init() { let match; if (!(match = location.search.match(/\bno=(\d+)/))) { return; } Captcha.replace.init(); this.postID = +match[1]; return $.ready(this.ready); }, ready() { $.addStyle(CSS.report); if (Conf['Archive Report']) { Report.archive(); } new MutationObserver(function () { Report.fit('iframe[src^="https://www.google.com/recaptcha/api2/frame"]'); return Report.fit('body'); }).observe(d.body, { childList: true, attributes: true, subtree: true }); return Report.fit('body'); }, fit(selector) { let el; if (!((el = $(selector, doc)) && (getComputedStyle(el).visibility !== 'hidden'))) { return; } const dy = (el.getBoundingClientRect().bottom - doc.clientHeight) + 8; if (dy > 0) { return window.resizeBy(0, dy); } }, archive() { let match, urls; if (!(urls = Redirect.report(g.BOARD.ID)).length) { return; } const form = $('form'); const types = $.id('reportTypes'); const message = $('h3'); const fieldset = $.el('fieldset', { id: 'archive-report', hidden: true }, { innerHTML: ReportPage }); const enabled = $('#archive-report-enabled', fieldset); const reason = $('#archive-report-reason', fieldset); const submit = $('#archive-report-submit', fieldset); $.on(enabled, 'change', function () { return reason.disabled = !this.checked; }); if (form && types) { fieldset.hidden = !$('[value="31"]', types).checked; $.on(types, 'change', function (e) { fieldset.hidden = (e.target.value !== '31'); return Report.fit('body'); }); $.after(types, fieldset); Report.fit('body'); $.one(form, 'submit', function (e) { if (!fieldset.hidden && enabled.checked) { e.preventDefault(); return Report.archiveSubmit(urls, reason.value, (results) => { this.action = '#archiveresults=' + encodeURIComponent(JSON.stringify(results)); return this.submit(); }); } }); } else if (message) { fieldset.hidden = /Report submitted!/.test(message.textContent); $.on(enabled, 'change', function () { return submit.hidden = !this.checked; }); $.after(message, fieldset); $.on(submit, 'click', () => Report.archiveSubmit(urls, reason.value, Report.archiveResults)); } if (match = location.hash.match(/^#archiveresults=(.*)$/)) { try { return Report.archiveResults(JSON.parse(decodeURIComponent(match[1]))); } catch (error) { } } }, archiveSubmit(urls, reason, cb) { const form = $.formData({ board: g.BOARD.ID, num: Report.postID, reason }); const results = []; for (var [name, url] of urls) { (function (name, url) { return $.ajax(url, { onloadend() { results.push([name, this.response || { error: '' }]); if (results.length === urls.length) { return cb(results); } }, form }); })(name, url); } }, archiveResults(results) { const fieldset = $.id('archive-report'); for (var [name, response] of results) { var line = $.el('h3', { className: 'archive-report-response' }); if ('success' in response) { $.addClass(line, 'archive-report-success'); line.textContent = `${name}: ${response.success}`; } else { $.addClass(line, 'archive-report-error'); line.textContent = `${name}: ${response.error || 'Error reporting post.'}`; } if (fieldset) { $.before(fieldset, line); } else { $.add(d.body, line); } } } }; const PostSuccessful = { init() { if (!Conf['Remember Your Posts']) { return; } return $.ready(this.ready); }, ready() { if (d.title !== 'Post successful!') { return; } let [_, threadID, postID] = $('h1').nextSibling.textContent.match(/thread:(\d+),no:(\d+)/); postID = +postID; threadID = +threadID || postID; const db = new DataBoard('yourPosts'); return db.set({ boardID: g.BOARD.ID, threadID, postID, val: true }); } }; function generatePostInfoHtml(ID, o, subject, capcode, email, name, tripcode, pass, capcodeLC, capcodePlural, staticPath, gifIcon, capcodeDescription, uniqueID, flag, flagCode, flagCodeTroll, dateUTC, dateText, postLink, quoteLink, boardID, threadID) { const nameHtml = [h("span", { class: `name${capcode ? ' capcode' : ''}` }, name)]; if (tripcode) nameHtml.push(' ', h("span", { class: "postertrip" }, tripcode)); if (pass) nameHtml.push(' ', h("span", { title: `Pass user since ${pass}`, class: "n-pu" })); if (capcode) { nameHtml.push(' ', h("strong", { class: `capcode hand id_${capcodeLC}`, title: `Highlight posts by ${capcodePlural}` }, "## ", capcode)); } const nameBlockContent = email ? [' ', h("a", { href: `mailto:${email}`, class: "useremail" }, ...nameHtml)] : nameHtml; if (!(boardID === "f" && !o.isReply || capcodeDescription)) nameBlockContent.push(' '); if (capcodeDescription) { nameBlockContent.push(h("img", { src: `${staticPath}${capcodeLC}icon${gifIcon}`, alt: `${capcode} Icon`, title: `This user is ${capcodeDescription}.`, class: "identityIcon retina" })); } if (uniqueID && !capcode) { nameBlockContent.push(h("span", { class: `posteruid id_${uniqueID}` }, "(ID: ", h("span", { class: "hand", title: "Highlight posts by this ID" }, uniqueID), ")")); } if (flagCode) nameBlockContent.push(' ', h("span", { title: flag, class: `flag flag-${flagCode.toLowerCase()}` })); if (flagCodeTroll) nameBlockContent.push(' ', h("span", { title: flag, class: `bfl bfl-${flagCodeTroll.toLowerCase()}` })); const postNumContent = [ h("a", { href: postLink, title: "Link to this post" }, "No."), h("a", { href: quoteLink, title: "Reply to this post" }, ID), ]; if (o.isSticky) { const src = `${staticPath}sticky${gifIcon}`; postNumContent.push(' '); if (boardID === "f") { postNumContent.push(h("img", { src: src, alt: "Sticky", title: "Sticky", style: "height: 18px; width: 18px;" })); } else { postNumContent.push(h("img", { src: src, alt: "Sticky", title: "Sticky", class: "stickyIcon retina" })); } } if (o.isClosed && !o.isArchived) { postNumContent.push(' '); const src = `${staticPath}closed${gifIcon}`; if (boardID === "f") { postNumContent.push(h("img", { src: src, alt: "Closed", title: "Closed", style: "height: 18px; width: 18px;" })); } else { postNumContent.push(h("img", { src: src, alt: "Closed", title: "Closed", class: "closedIcon retina" })); } } if (o.isArchived) { postNumContent.push(' ', h("img", { src: `${staticPath}archived${gifIcon}`, alt: "Archived", title: "Archived", class: "archivedIcon retina" })); } if (!o.isReply && g.VIEW === "index") { postNumContent.push(' \u00A0 ', h("span", null, "[", h("a", { href: `/${boardID}/thread/${threadID}`, class: "replylink" }, "Reply"), "]")); } return h("div", { class: "postInfo desktop", id: `pi${ID}` }, h("input", { type: "checkbox", name: ID, value: "delete" }), ' ', ...((!o.isReply || boardID === "f" || subject) ? [h("span", { class: "subject" }, subject), ' '] : []), h("span", { class: `nameBlock${capcode ? ` capcode${capcode}` : ''}` }, ...nameBlockContent), ' ', h("span", { class: "dateTime", "data-utc": dateUTC }, dateText), ' ', h("span", { class: `postNum${!(boardID === " f" && !o.isReply) ? ' desktop' : ''}` }, ...postNumContent)); } function generateFileHtml(file, ID, boardID, fileURL, shortFilename, fileThumb, o, staticPath, gifIcon) { if (file) { const fileContent = []; if (boardID === "f") { fileContent.push(h("div", { class: "fileInfo", "data-md5": file.MD5 }, h("span", { class: "fileText", id: `fT${ID}` }, 'File: ', h("a", { "data-width": file.width, "data-height": file.height, href: fileURL, target: "_blank" }, file.name), "-(", file.size, ", ", file.dimensions, file.tag ? ', ' + file.tag : '', ")"))); } else { fileContent.push(h("div", { class: "fileText", id: `fT${ID}`, title: file.isSpoiler ? file.name : null }, 'File: ', h("a", { title: file.name === shortFilename || file.isSpoiler ? null : file.name, href: fileURL, target: "_blank" }, file.isSpoiler ? 'Spoiler Image' : shortFilename), ` (${file.size}, ${file.dimensions || "PDF"})`), h("a", { class: `fileThumb${file.isSpoiler ? ' imgspoiler' : ''}`, href: fileURL, target: "_blank", "data-m": file.hasDownscale ? '' : null }, h("img", { src: fileThumb, alt: file.size, "data-md5": file.MD5, style: `height: ${file.isSpoiler ? '100' : file.theight}px; width: ${file.isSpoiler ? '100' : file.twidth}px;`, loading: "lazy" }))); } return h("div", { class: "file", id: `f${ID}` }, ...fileContent); } else if (o.fileDeleted) { return h("div", { class: "file", id: `f${ID}` }, h("span", { class: "fileThumb" }, h("img", { src: `${staticPath}filedeleted-res${gifIcon}`, alt: "File deleted.", class: "fileDeletedRes retina" }))); } return { innerHTML: '', [isEscaped]: true }; } function generateCatalogThreadHtml(thread, src, imgClass, data, postCount, fileCount, pageCount, staticPath, gifIcon) { return h(hFragment, null, h("a", { class: "catalog-link", href: `/${thread.board}/thread/${thread.ID}` }, imgClass ? h("img", { src: src, class: `catalog-thumb ${imgClass}` }) : h("img", { src: src, class: "catalog-thumb", "data-width": data.tn_w, "data-height": data.tn_h })), h("div", { class: "catalog-stats" }, h("span", { title: "Posts / Files / Page" }, h("span", { class: `post-count${data.bumplimit ? ' warning' : ''}` }, postCount), ' / ', h("span", { class: `file-count${data.imagelimit ? ' warning' : ''}` }, fileCount), ' / ', h("span", { class: "page-count" }, pageCount)), h("span", { class: "catalog-icons" }, thread.isSticky ? h("img", { src: `${staticPath}sticky${gifIcon}`, class: "stickyIcon", title: "Sticky" }) : '', thread.isClosed ? h("img", { src: `${staticPath}closed${gifIcon}`, class: "closedIcon", title: "Closed" }) : ''))); } const SWYotsuba = { isOPContainerThread: false, hasIPCount: true, archivedBoardsKnown: true, urls: { thread({ boardID, threadID }) { return `${location.protocol}//${BoardConfig.domain(boardID)}/${boardID}/thread/${threadID}`; }, post({ postID }) { return `#p${postID}`; }, index({ boardID }) { return `${location.protocol}//${BoardConfig.domain(boardID)}/${boardID}/`; }, catalog({ boardID }) { if (boardID === 'f') { return undefined; } else { return `${location.protocol}//${BoardConfig.domain(boardID)}/${boardID}/catalog`; } }, archive({ boardID }) { if (BoardConfig.isArchived(boardID)) { return `${location.protocol}//${BoardConfig.domain(boardID)}/${boardID}/archive`; } else { return undefined; } }, threadJSON({ boardID, threadID }) { return `${location.protocol}//a.4cdn.org/${boardID}/thread/${threadID}.json`; }, threadsListJSON({ boardID }) { return `${location.protocol}//a.4cdn.org/${boardID}/threads.json`; }, archiveListJSON({ boardID }) { if (BoardConfig.isArchived(boardID)) { return `${location.protocol}//a.4cdn.org/${boardID}/archive.json`; } else { return ''; } }, catalogJSON({ boardID }) { return `${location.protocol}//a.4cdn.org/${boardID}/catalog.json`; }, file({ boardID }, filename) { const hostname = boardID === 'f' ? ImageHost.flashHost() : ImageHost.host(); return `${location.protocol}//${hostname}/${boardID}/${filename}`; }, thumb({ boardID }, filename) { return `${location.protocol}//${ImageHost.thumbHost()}/${boardID}/${filename}`; } }, isPrunedByAge({ boardID }) { return boardID === 'f'; }, areMD5sDeferred({ boardID }) { return boardID === 'f'; }, isOnePage({ boardID }) { return boardID === 'f'; }, noAudio({ boardID }) { return BoardConfig.noAudio(boardID); }, selectors: { board: '.board', thread: '.thread', threadDivider: '.board > hr', summary: 'a.summary', postContainer: '.postContainer', replyOriginal: '.replyContainer:not([data-clone])', sideArrows: 'div.sideArrows', post: '.post', infoRoot: '.postInfo', info: { subject: '.subject', name: '.name', email: '.useremail', tripcode: '.postertrip', uniqueIDRoot: '.posteruid', uniqueID: '.posteruid > .hand', capcode: '.capcode.hand', pass: '.n-pu', flag: '.flag, .bfl', date: '.dateTime', nameBlock: '.nameBlock', quote: '.postNum > a:nth-of-type(2)', reply: '.replylink' }, icons: { isSticky: '.stickyIcon', isClosed: '.closedIcon', isArchived: '.archivedIcon' }, file: { text: '.fileText', link: '.fileText > a', thumb: 'a.fileThumb > [data-md5]' }, thumbLink: 'a.fileThumb', highlightable: { op: '.opContainer', reply: ' > .reply', catalog: '' }, comment: '.postMessage', spoiler: 's', quotelink: ':not(pre) > .quotelink', catalog: { board: '#threads', thread: '.thread', thumb: '.thumb' }, boardList: '#boardNavDesktop > .boardList', boardListBottom: '#boardNavDesktopFoot > .boardList', styleSheet: 'link[title=switch]', psa: '#globalMessage', psaTop: '#globalToggle', searchBox: '#search-box', nav: { prev: '.prev > form > [type=submit]', next: '.next > form > [type=submit]' } }, classes: { highlight: 'highlight' }, xpath: { thread: 'div[contains(concat(" ",@class," ")," thread ")]', postContainer: 'div[contains(@class,"postContainer")]', replyContainer: 'div[contains(@class,"replyContainer")]' }, regexp: { quotelink: new RegExp(`\ ^https?://boards\\.4chan(?:nel)?\\.org/+\ ([^/]+)\ /+thread/+\ (\\d+)\ (?:[/?][^#]*)?\ (?:#p\ (\\d+)\ )?\ $\ `), quotelinkHTML: /]*\bhref="(?:(?:\/\/boards\.4chan(?:nel)?\.org)?\/([^\/]+)\/thread\/)?(\d+)?(?:#p(\d+))?"/g, pass: /^https?:\/\/www\.4chan(?:nel)?\.org\/+pass(?:$|[?#])/, captcha: /^https?:\/\/sys\.4chan(?:nel)?\.org\/+captcha(?:$|[?#])/, }, bgColoredEl() { return $.el('div', { className: 'reply' }); }, isThisPageLegit() { return ['boards.4chan.org', 'boards.4channel.org'].includes(location.hostname) && d.doctype && !$('link[href*="favicon-status.ico"]', d.head) && !['4chan - Temporarily Offline', '4chan - Error', '504 Gateway Time-out', 'MathJax Equation Source'].includes(d.title); }, is404() { return ['4chan - Temporarily Offline', '4chan - 404 Not Found'].includes(d.title) || ((g.VIEW === 'thread') && $('.board') && !$('.opContainer')); }, isIncomplete() { return (g.VIEW === 'index' || g.VIEW === 'thread') && !$('.board + *'); }, isBoardlessPage(url) { return ['www.4chan.org', 'www.4channel.org'].includes(url.hostname); }, isAuxiliaryPage(url) { return !['boards.4chan.org', 'boards.4channel.org'].includes(url.hostname); }, isFileURL(url) { return ImageHost.test(url.hostname); }, initAuxiliary() { switch (location.hostname) { case 'www.4chan.org': case 'www.4channel.org': if (SWYotsuba.regexp.pass.test(location.href)) { PassMessage.init(); } else { $.onExists(doc, 'body', () => $.addStyle(CSS.www)); Captcha.replace.init(); } return; case 'sys.4chan.org': case 'sys.4channel.org': var pathname = location.pathname.split(/\/+/); if (pathname[2] === 'imgboard.php') { let match; if (/\bmode=report\b/.test(location.search)) { Report.init(); } else if (match = location.search.match(/\bres=(\d+)/)) { $.ready(function () { if (Conf['404 Redirect'] && g.BOARD && ($.id('errmsg')?.textContent === 'Error: Specified thread does not exist.')) { return Redirect.navigate('thread', { boardID: g.BOARD.ID, postID: +match[1] }); } }); } } else if (pathname[2] === 'post') { PostSuccessful.init(); } return; } }, scriptData() { for (var script of $$('script:not([src])', d.head)) { if (/\bcooldowns *=/.test(script.textContent)) { return script.textContent; } } return ''; }, parseThreadMetadata(thread) { let m; const scriptData = this.scriptData(); thread.postLimit = /\bbumplimit *= *1\b/.test(scriptData); thread.fileLimit = /\bimagelimit *= *1\b/.test(scriptData); thread.ipCount = ((m = scriptData.match(/\bunique_ips *= *(\d+)\b/)) ? +m[1] : undefined); if ((g.BOARD?.ID === 'f') && thread.OP.file) { const { file } = thread.OP; return $.ajax(this.urls.threadJSON({ boardID: 'f', threadID: thread.ID }), { timeout: MINUTE, onloadend() { if (this.response) { return file.text.dataset.md5 = (file.MD5 = this.response.posts[0].md5); } } }); } }, parseNodes(post, nodes) { if (post.boardID === 'f') { return (() => { const result = []; for (var type of ['Sticky', 'Closed']) { var icon; if (icon = $(`img[alt=${type}]`, nodes.info)) { result.push($.addClass(icon, `${type.toLowerCase()}Icon`, 'retina')); } } return result; })(); } }, parseDate(node) { return new Date(Number(node.dataset.utc) * 1000); }, parseFile(post, file) { let info; const { text, link, thumb } = file; if (!(info = link.nextSibling?.textContent.match(/\(([\d.]+ [KMG]?B).*\)/))) { return false; } $.extend(file, { name: text.title || link.title || link.textContent, size: info[1], dimensions: info[0].match(/\d+x\d+/)?.[0], tag: info[0].match(/,[^,]*, ([a-z]+)\)/i)?.[1], MD5: text.dataset.md5 || text.parentElement?.dataset.md5 }); if (thumb) { $.extend(file, { thumbURL: thumb.src, MD5: thumb.dataset.md5, isSpoiler: $.hasClass(thumb.parentNode, 'imgspoiler') }); if (file.isSpoiler) { let m; file.thumbURL = (m = link.href.match(/\d+(?=\.\w+$)/)) ? `${location.protocol}//${ImageHost.thumbHost()}/${post.board}/${m[0]}s.jpg` : undefined; } } return true; }, cleanComment(bq) { let abbr; if (abbr = $('.abbr', bq)) { for (var node of $$('.abbr + br, .exif', bq)) { $.rm(node); } for (let i = 0; i < 2; i++) { var br; if ((br = abbr.previousSibling) && (br.nodeName === 'BR')) { $.rm(br); } } return $.rm(abbr); } }, cleanCommentDisplay(bq) { let b; if ((b = $('b', bq)) && /^Rolled /.test(b.textContent)) { $.rm(b); } return $.rm($('.fortune', bq)); }, insertTags(bq) { let node; for (node of $$('s, .removed-spoiler', bq)) { $.replace(node, [$.tn('[spoiler]'), ...node.childNodes, $.tn('[/spoiler]')]); } for (node of $$('.prettyprint', bq)) { $.replace(node, [$.tn('[code]'), ...node.childNodes, $.tn('[/code]')]); } }, hasCORS(url) { return url.split('/').slice(0, 3).join('/') === (location.protocol + '//a.4cdn.org'); }, sfwBoards(sfw) { return BoardConfig.sfwBoards(sfw); }, uidColor(uid) { let msg = 0; let i = 0; while (i < 8) { msg = ((msg << 5) - msg) + uid.charCodeAt(i++); } return (msg >> 8) & 0xFFFFFF; }, isLinkified(link) { return ImageHost.test(link.hostname); }, testNativeExtension() { return $.global('testNativeExtension', {}); }, transformBoardList() { let node; const nodes = []; const spacer = () => $.el('span', { className: 'spacer' }); const items = $.X('.//a|.//text()[not(ancestor::a)]', $(SWYotsuba.selectors.boardList)); let i = 0; while ((node = items.snapshotItem(i++))) { switch (node.nodeName) { case '#text': for (var chr of node.nodeValue || '') { var span = $.el('span', { textContent: chr }); if (chr === ' ') { span.className = 'space'; } if (chr === ']') { nodes.push(spacer()); } nodes.push(span); if (chr === '[') { nodes.push(spacer()); } } break; case 'A': var a = node.cloneNode(true); nodes.push(a); break; } } return nodes; }, Build: { staticPath: '//s.4cdn.org/image/', gifIcon: window.devicePixelRatio >= 2 ? '@2x.gif' : '.gif', spoilerRange: Object.create(null), shortFilename(filename) { const ext = filename.match(/\.?[^\.]*$/)[0]; if ((filename.length - ext.length) > 30) { return `${filename.match(/(?:[\uD800-\uDBFF][\uDC00-\uDFFF]|[^]){0,25}/)[0]}(...)${ext}`; } else { return filename; } }, spoilerThumb(boardID) { let spoilerRange; if ((spoilerRange = this.spoilerRange[boardID])) { return `${this.staticPath}spoiler-${boardID}${Math.floor(1 + (spoilerRange * Math.random()))}.png`; } else { return `${this.staticPath}spoiler.png`; } }, sameThread(boardID, threadID) { return (g.VIEW === 'thread') && (g.BOARD?.ID === boardID) && (g.THREADID === +threadID); }, threadURL(boardID, threadID) { if (boardID !== g.BOARD?.ID) { return `//${BoardConfig.domain(boardID)}/${boardID}/thread/${threadID}`; } else if ((g.VIEW !== 'thread') || (+threadID !== g.THREADID)) { return `/${boardID}/thread/${threadID}`; } else { return ''; } }, postURL(boardID, threadID, postID) { return `${this.threadURL(boardID, threadID)}#p${postID}`; }, parseJSON(data, { siteID, boardID }) { const o = { ID: data.no, postID: data.no, threadID: data.resto || data.no, boardID, siteID, isReply: !!data.resto, isSticky: !!data.sticky, isClosed: !!data.closed, isArchived: !!data.archived, threadReplies: data.replies, threadImages: data.images, fileDeleted: !!data.filedeleted, filesDeleted: data.filedeleted ? [0] : [] }; o.info = { subject: $.unescape(data.sub), email: $.unescape(data.email), name: $.unescape(data.name) || '', tripcode: data.trip, pass: (data.since4pass != null) ? `${data.since4pass}` : undefined, uniqueID: data.id, flagCode: data.country, flagCodeTroll: data.board_flag, flag: $.unescape((data.country_name || data.flag_name)), dateUTC: data.time, dateText: data.now, commentHTML: { innerHTML: data.com || '', [isEscaped]: true } }; if (data.capcode) { o.info.capcode = data.capcode.replace(/_highlight$/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); o.capcodeHighlight = /_highlight$/.test(data.capcode); delete o.info.uniqueID; } o.files = []; if (data.ext) { o.file = this.parseJSONFile(data, { siteID, boardID }); o.files.push(o.file); } o.extra = dict(); for (var key in data) { if (key[0] === 'x') { o.extra[key] = data[key]; } } return o; }, parseJSONFile(data, { siteID, boardID }) { const site = g.sites[siteID]; const filename = (site.software === 'yotsuba') && (boardID === 'f') ? `${encodeURIComponent(data.filename)}${data.ext}` : `${data.tim}${data.ext}`; const o = { name: ($.unescape(data.filename)) + data.ext, url: site.urls.file({ siteID, boardID }, filename), height: data.h, width: data.w, MD5: data.md5, size: $.bytesToString(data.fsize), thumbURL: site.urls.thumb({ siteID, boardID }, `${data.tim}s.jpg`), theight: data.tn_h, twidth: data.tn_w, isSpoiler: !!data.spoiler, tag: data.tag, hasDownscale: !!data.m_img }; if ((data.h != null) && !/\.pdf$/.test(o.url)) { o.dimensions = `${o.width}x${o.height}`; } return o; }, parseComment(html) { html = html .replace(//gi, '\n') .replace(/\n\n]*>/g, ''); return $.unescape(html); }, parseCommentDisplay(html) { if (!Conf['Remove Spoilers'] && !Conf['Reveal Spoilers']) { let html2; while ((html2 = html.replace(/(?:(?!<\/?s>).)*<\/s>/g, '[spoiler]')) !== html) { html = html2; } } html = html .replace(/^Rolled [^<]*<\/b>/i, '') .replace(/>") : ''), h("div", { id: `p${ID}`, class: `post ${postClass}${o.capcodeHighlight ? ' highlightPost' : ''}` }, ...postContent)); const container = $.el('div', { className: `postContainer ${postClass}Container`, id: `pc${ID}` }); $.extend(container, wholePost); for (var quote of $$('.quotelink', container)) { var href = quote.getAttribute('href'); if (href[0] === '#') { if (!this.sameThread(boardID, threadID)) { quote.href = this.threadURL(boardID, threadID) + href; } } else { var match; if ((match = quote.href.match(SWYotsuba.regexp.quotelink)) && (this.sameThread(match[1], match[2]))) { quote.href = href.match(/(#[^#]*)?$/)[0] || '#'; } } } return container; }, summaryText(status, posts, files, hoverPreview = false) { let text = ''; if (status) text += `${status} `; text += `${posts} post${posts == 1 ? '' : 's'}`; if (+files) text += ` and ${files} image repl${files > 1 ? 'ies' : 'y'}`; return hoverPreview ? text : `${text} ${status === '-' ? 'shown' : 'omitted'}.`; }, summary(boardID, threadID, posts, files) { return $.el('a', { className: 'summary', textContent: this.summaryText('', posts, files), href: `/${boardID}/thread/${threadID}` }); }, thread(thread, data, withReplies) { let root; if (root = thread.nodes.root) { $.rmAll(root); } else { thread.nodes.root = (root = $.el('div', { className: 'thread', id: `t${data.no}` })); } if (this.hat) { $.add(root, this.hat.cloneNode(false)); } $.add(root, thread.OP.nodes.root); if (data.omitted_posts || (!withReplies && data.replies)) { const [posts, files] = withReplies ? [data.omitted_posts, data.images - data.last_replies.filter((data) => !!data.ext).length] : [data.replies, data.images]; const summary = this.summary(thread.board.ID, data.no, posts, files); $.add(root, summary); } return root; }, catalogThread(thread, data, pageCount) { let cssText, imgClass, src; const { staticPath, gifIcon } = this; const { tn_w, tn_h } = data; if (data.spoiler && !Conf['Reveal Spoiler Thumbnails']) { let spoilerRange; src = `${staticPath}spoiler`; if (spoilerRange = this.spoilerRange[thread.board]) { src += (`-${thread.board}`) + Math.floor(1 + (spoilerRange * Math.random())); } src += '.png'; imgClass = 'spoiler-file'; cssText = "--tn-w: 100; --tn-h: 100;"; } else if (data.filedeleted) { src = `${staticPath}filedeleted-res${gifIcon}`; imgClass = 'deleted-file'; } else if (thread.OP.file) { src = thread.OP.file.thumbURL; const ratio = 250 / Math.max(tn_w, tn_h); cssText = `--tn-w: ${tn_w * ratio}; --tn-h: ${tn_h * ratio};`; } else { src = `${staticPath}nofile.png`; imgClass = 'no-file'; } const postCount = data.replies + 1; const fileCount = data.images + !!data.ext; const container = $.el('div', generateCatalogThreadHtml(thread, src, imgClass, data, postCount, fileCount, pageCount, staticPath, gifIcon)); $.before(thread.OP.nodes.info, [...container.childNodes]); for (var br of $$('br', thread.OP.nodes.comment)) { if (br.previousSibling && (br.previousSibling.nodeName === 'BR')) { $.addClass(br, 'extra-linebreak'); } } const root = $.el('div', { className: 'thread catalog-thread', id: `t${thread}` }); if (thread.OP.highlights) { $.addClass(root, ...thread.OP.highlights); } if (!thread.OP.file) { $.addClass(root, 'noFile'); } root.style.cssText = cssText || ''; return root; }, catalogReply(thread, data) { let excerpt = ''; if (data.com) { excerpt = this.parseCommentDisplay(data.com).replace(/>>\d+/g, '').trim().replace(/\n+/g, ' // '); } if (data.ext) { if (!excerpt) { excerpt = `${$.unescape(data.filename)}${data.ext}`; } } if (data.com) { if (!excerpt) { excerpt = $.unescape(data.com.replace(//gi, ' // ')); } } if (!excerpt) { excerpt = '\xA0'; } if (excerpt.length > 73) { excerpt = `${excerpt.slice(0, 70)}...`; } const link = this.postURL(thread.board.ID, thread.ID, data.no); return $.el('div', { className: 'catalog-reply' }, h(hFragment, null, h("span", null, h("time", { "data-utc": data.time * 1000, "data-abbrev": "1" }, "..."), ": "), h("a", { class: "catalog-reply-excerpt", href: link }, excerpt), h("a", { class: "catalog-reply-preview", href: link }, "..."))); } } }; const SWTinyboard = { isOPContainerThread: true, mayLackJSON: true, threadModTimeIgnoresSage: true, disabledFeatures: [ 'Resurrect Quotes', 'Quick Reply Personas', 'Quick Reply', 'Cooldown', 'Report Link', 'Delete Link', 'Edit Link', 'Quote Inlining', 'Quote Previewing', 'Quote Backlinks', 'File Info Formatting', 'Image Expansion', 'Image Expansion (Menu)', 'Comment Expansion', 'Thread Expansion', 'Favicon', 'Quote Threading', 'Thread Updater', 'Banner', 'Flash Features', 'Reply Pruning' ], detect() { for (var script of $$('script:not([src])', d.head)) { var m; if (m = script.textContent.match(/\bvar configRoot=(".*?")/)) { var properties = dict(); try { var root = JSON.parse(m[1]); if (root[0] === '/') { properties.root = location.origin + root; } else if (/^https?:/.test(root)) { properties.root = root; } } catch (error) { } return properties; } } return false; }, awaitBoard(cb) { if ($.id('react-ui')) { const s = (this.selectors = Object.create(this.selectors)); s.boardFor = { index: '.page-container' }; s.thread = 'div[id^="thread_"]'; return Main.mounted(cb); } else { return cb(); } }, urls: { thread({ siteID, boardID, threadID }, isArchived) { return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/${isArchived ? 'archive/' : ''}res/${threadID}.html`; }, post({ postID }) { return `#${postID}`; }, index({ siteID, boardID }) { return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/`; }, catalog({ siteID, boardID }) { return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/catalog.html`; }, threadJSON({ siteID, boardID, threadID }, isArchived) { const root = Conf['siteProperties'][siteID]?.root; if (root) { return `${root}${boardID}/${isArchived ? 'archive/' : ''}res/${threadID}.json`; } else { return ''; } }, archivedThreadJSON(thread) { return SWTinyboard.urls.threadJSON(thread, true); }, threadsListJSON({ siteID, boardID }) { const root = Conf['siteProperties'][siteID]?.root; if (root) { return `${root}${boardID}/threads.json`; } else { return ''; } }, archiveListJSON({ siteID, boardID }) { const root = Conf['siteProperties'][siteID]?.root; if (root) { return `${root}${boardID}/archive/archive.json`; } else { return ''; } }, catalogJSON({ siteID, boardID }) { const root = Conf['siteProperties'][siteID]?.root; if (root) { return `${root}${boardID}/catalog.json`; } else { return ''; } }, file({ siteID, boardID }, filename) { return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/${filename}`; }, thumb(board, filename) { return SWTinyboard.urls.file(board, filename); } }, selectors: { board: 'form[name="postcontrols"]', thread: 'input[name="board"] ~ div[id^="thread_"]', threadDivider: 'div[id^="thread_"] > hr:last-child', summary: '.omitted', postContainer: 'div[id^="reply_"]:not(.hidden)', opBottom: '.op', replyOriginal: 'div[id^="reply_"]:not(.hidden)', infoRoot: '.intro', info: { subject: '.subject', name: '.name', email: '.email', tripcode: '.trip', uniqueID: '.poster_id', capcode: '.capcode', flag: '.flag', date: 'time', nameBlock: 'label', quote: 'a[href*="#q"]', reply: 'a[href*="/res/"]:not([href*="#"])' }, icons: { isSticky: '.fa-thumb-tack', isClosed: '.fa-lock' }, file: { text: '.fileinfo', link: '.fileinfo > a', thumb: 'a > .post-image' }, thumbLink: '.file > a', multifile: '.files > .file', highlightable: { op: ' > .op', reply: '.reply', catalog: ' > .thread' }, comment: '.body', spoiler: '.spoiler', quotelink: 'a[onclick*="highlightReply("]', catalog: { board: '#Grid', thread: '.mix', thumb: '.thread-image' }, boardList: '.boardlist', boardListBottom: '.boardlist.bottom', styleSheet: '#stylesheet', psa: '.blotter', nav: { prev: '.pages > form > [value=Previous]', next: '.pages > form > [value=Next]' } }, classes: { highlight: 'highlighted' }, xpath: { thread: 'div[starts-with(@id,"thread_")]', postContainer: 'div[starts-with(@id,"reply_") or starts-with(@id,"thread_")]', replyContainer: 'div[starts-with(@id,"reply_")]' }, regexp: { quotelink: new RegExp(`\ /\ ([^/]+)\ /res/\ (\\d+)\ (?:\\.\\w+)?#\ (\\d+)\ $\ `), quotelinkHTML: /]*\bhref="[^"]*\/([^\/]+)\/res\/(\d+)(?:\.\w+)?#(\d+)"/g }, Build: { parseJSON(data, board) { const o = SWYotsuba.Build.parseJSON(data, board); if (data.ext === 'deleted') { delete o.file; $.extend(o, { files: [], fileDeleted: true, filesDeleted: [0] }); } else if (o.file) { o.file = this.parseJSONFile(data, board); o.files = [o.file]; } if (data.extra_files) { let file; for (let i = 0; i < data.extra_files.length; i++) { var extra_file = data.extra_files[i]; if (extra_file.ext === 'deleted') { o.filesDeleted.push(i); } else { file = SWYotsuba.Build.parseJSONFile(data, board); o.files.push(file); } } if (o.files.length) { o.file = o.files[0]; } } return o; }, parseJSONFile(data, board) { const o = SWYotsuba.Build.parseJSONFile(data, board); const { siteID, boardID } = board; const thumbExt = Conf['siteProperties'][siteID]?.thumbExt || '.png'; o.url = SWTinyboard.urls.file({ siteID, boardID }, `src/${data.tim}${data.ext}`); o.thumbURL = SWTinyboard.urls.thumb({ siteID, boardID }, `thumb/${data.tim}${thumbExt}`); return o; }, parseComment(html) { html = html .replace(//gi, '\n') .replace(/<[^>]*>/g, ''); return $.unescape(html); } }, bgColoredEl() { return $.el('div', { className: 'post reply' }); }, isFileURL(url) { return /\/src\/[^\/]+/.test(url.pathname); }, preParsingFixes(board) { let broken; if (broken = $('a > input[name="board"]', board)) { return $.before(broken.parentNode, broken); } }, parseNodes(post, nodes) { let m; if (nodes.uniqueID) { return; } let text = ''; let node = nodes.nameBlock.nextSibling; while (node && (node.nodeType === 3)) { text += node.textContent; node = node.nextSibling; } if (m = text.match(/(\s*ID:\s*)(\S+)/)) { let uniqueID; nodes.info.normalize(); let { nextSibling } = nodes.nameBlock; nextSibling = nextSibling.splitText(m[1].length); nextSibling.splitText(m[2].length); nodes.uniqueID = (uniqueID = $.el('span', { className: 'poster_id' })); $.replace(nextSibling, uniqueID); return $.add(uniqueID, nextSibling); } }, parseDate(node) { let date = Date.parse(node.getAttribute('datetime')?.trim()); if (!isNaN(date)) { return new Date(date); } date = Date.parse(node.textContent.trim() + ' UTC'); if (!isNaN(date)) { return new Date(date); } return undefined; }, parseFile(post, file) { let info, infoNode; const { text, link, thumb } = file; if ($.x(`ancestor::${this.xpath.postContainer}[1]`, text) !== post.nodes.root) { return false; } if (!(infoNode = link.nextSibling?.textContent.includes('(') ? link.nextSibling : link.nextElementSibling)) { return false; } if (!(info = infoNode.textContent.match(/\((.*,\s*)?([\d.]+ ?[KMG]?B).*\)/))) { return false; } const nameNode = $('.postfilename', text); $.extend(file, { name: nameNode ? (nameNode.title || nameNode.textContent) : link.pathname.match(/[^/]*$/)[0], size: info[2], dimensions: info[0].match(/\d+x\d+/)?.[0] }); if (thumb) { $.extend(file, { thumbURL: /\/static\//.test(thumb.src) && $.isImage(link.href) ? link.href : thumb.src, isSpoiler: /^Spoiler/i.test(info[1] || '') || (link.textContent === 'Spoiler Image') }); } return true; }, isThumbExpanded(file) { return $.hasClass(file.thumb.parentNode, 'expanded') || (file.thumb.parentNode.dataset.expanded === 'true'); }, isLinkified(link) { return /\bnofollow\b/.test(link.rel); }, catalogPin(threadRoot) { return threadRoot.dataset.sticky = 'true'; } }; const SW = { tinyboard: SWTinyboard, yotsuba: SWYotsuba }; var FileInfo = { init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive') || !Conf['File Info Formatting']) { return; } return Callbacks.Post.push({ name: 'File Info Formatting', cb: this.node }); }, node() { if (!this.file) { return; } if (this.isClone) { let a; for (a of $$('.file-info .download-button', this.file.text)) { $.on(a, 'click', ImageCommon.download); } for (a of $$('.file-info .quick-filter-md5', this.file.text)) { $.on(a, 'click', Filter.quickFilterMD5); } return; } const oldInfo = $.el('span', { className: 'fileText-original' }); $.prepend(this.file.link.parentNode, oldInfo); $.add(oldInfo, [this.file.link.previousSibling, this.file.link, this.file.link.nextSibling]); const info = $.el('span', { className: 'file-info' }); FileInfo.format(Conf['fileInfo'], this, info); return $.prepend(this.file.text, info); }, format(formatString, post, outputNode) { let a; const output = []; formatString.replace(/%(.)|[^%]+/g, function (s, c) { output.push($.hasOwn(FileInfo.formatters, c) ? FileInfo.formatters[c].call(post) : { innerHTML: E(s) }); return ''; }); $.extend(outputNode, { innerHTML: E.cat(output) }); for (a of $$('.download-button', outputNode)) { $.on(a, 'click', ImageCommon.download); } for (a of $$('.quick-filter-md5', outputNode)) { $.on(a, 'click', Filter.quickFilterMD5); } }, formatters: { t() { return { innerHTML: E(this.file.url.match(/[^/]*$/)[0]), [isEscaped]: true }; }, T() { return h("a", { href: this.file.url, target: "_blank" }, FileInfo.formatters.t.call(this)); }, l() { return h("a", { href: this.file.url, target: "_blank" }, FileInfo.formatters.n.call(this)); }, L() { return h("a", { href: this.file.url, target: "_blank" }, FileInfo.formatters.N.call(this)); }, n() { const fullname = this.file.name; const shortname = SW.yotsuba.Build.shortFilename(this.file.name, this.isReply); if (fullname === shortname) { return { innerHTML: E(fullname), [isEscaped]: true }; } else { return h("span", { class: "fnswitch" }, h("span", { class: "fntrunc" }, shortname), h("span", { class: "fnfull" }, fullname)); } }, N() { return { innerHTML: E(this.file.name), [isEscaped]: true }; }, d() { return h("a", { href: this.file.url, download: this.file.name, class: "download-button" }, Icon.raw('download')); }, f() { return { innerHTML: `${Icon.get('xmark')}`, [isEscaped]: true, }; }, p() { return { innerHTML: ((this.file.isSpoiler) ? "Spoiler, " : ""), [isEscaped]: true }; }, s() { return { innerHTML: E(this.file.size), [isEscaped]: true }; }, B() { return { innerHTML: Math.round(this.file.sizeInBytes) + " Bytes", [isEscaped]: true }; }, K() { return { innerHTML: (Math.round(this.file.sizeInBytes / 1024)) + " KB", [isEscaped]: true }; }, M() { return { innerHTML: (Math.round(this.file.sizeInBytes / 1048576 * 100) / 100) + " MB", [isEscaped]: true }; }, r() { return { innerHTML: E(this.file.dimensions || "PDF"), [isEscaped]: true }; }, g() { return { innerHTML: ((this.file.tag) ? ", " + E(this.file.tag) : ""), [isEscaped]: true }; }, '%'() { return { innerHTML: "%", [isEscaped]: true }; } } }; const NAV_KEYWORDS = new Set([ 'toggle', 'external', 'current', 'all', 'title', 'replace', 'full', 'index', 'catalog', 'archive', 'expired', 'nt', 'mode', 'sort', 'text', ]); function parseFavoriteBoards(value) { if (!value) { return []; } const seen = new Set(); for (const token of value.split(/[\s,]+/)) { const code = token.toLowerCase(); if (/^[a-z0-9]+$/.test(code)) { seen.add(code); } } return [...seen].sort(); } function serializeFavoriteBoards(codes) { return [...new Set(codes)].sort().join(' '); } function toggleFavorite(favorites, code) { return favorites.includes(code) ? favorites.filter(c => c !== code) : [...favorites, code].sort(); } function seedFavoritesFromBoardnav(boardnav) { if (!boardnav) { return []; } const cleaned = boardnav .replace(/(\r\n|\n|\r)/g, ' ') .replace(/-?(?:text|mode|sort):"[^"]*"(?:,"[^"]*")?/g, ' ') .replace(/\{\{(?:"[^"]+")?|\}\}/g, ' ') .replace(/[{}[\]()|/\\]/g, ' '); const seen = new Set(); for (const token of cleaned.split(/\s+/)) { const base = token.split('-')[0].toLowerCase(); if (!base || !/^[a-z0-9]+$/.test(base)) { continue; } if (NAV_KEYWORDS.has(base)) { continue; } seen.add(base); } return [...seen].sort(); } function filterBoards(boards, query) { const q = query.trim().toLowerCase(); if (!q) { return boards; } if (q.startsWith('/')) { const closed = q.length > 1 && q.endsWith('/'); const code = q.replace(/^\/+|\/+$/g, ''); if (!code) { return boards; } return boards.filter(b => closed ? b.code.toLowerCase() === code : b.code.toLowerCase().startsWith(code)); } return boards.filter(b => b.code.toLowerCase().includes(q) || b.title.toLowerCase().includes(q)); } function defaultTab(favoriteCount) { return favoriteCount > 0 ? 'favorites' : 'all'; } const SETTINGS_SEARCH_HL = 'fourchanx-settings-search'; const SEARCH_HIGHLIGHT_SELECTOR = '.setting-title, .setting-description, .settings-section-header, .styling-section-summary-text, .next-summary-title, summary, th, h4'; const SETTINGS_COMPACT_CONTENT_WIDTH = 760; const SETTINGS_COLLAPSED_NAV_WIDTH = 640; const NEXT_ADDED = new Set(nextSettingsDiff.added); const NEXT_CHANGED = new Set(nextSettingsDiff.changed); function nextStatusOf(key) { if (!key) return ''; if (NEXT_ADDED.has(key)) return 'added'; if (NEXT_CHANGED.has(key)) return 'changed'; return ''; } var Settings = { dialog: undefined, searchQuery: '', searchTerms: [], activeSection: null, renderedSection: null, clickKeybinds: ['Watch (catalog click)', 'Hide thread (catalog click)'], isClickKeybind(name) { return Settings.clickKeybinds.includes(name); }, rememberLayout: false, highlightNext: false, accordionMode: false, settingsNavResizeObserver: null, settingsNavResizeFallback: null, descriptionTooltipAutoPrevious: null, descriptionTooltipAutoActive: false, savedWindowLayout: '', detailsState: dict(), pointerDownInsideDialog: false, customCSSEditorThemeObserver: null, customCSSFormatOptions: { indent: ' ', blankLines: true, selectorPerLine: true }, activeSiteStylePicker: null, siteStylePickerOutsideHandler: null, textareaSavedFlashTimers: new WeakMap(), stylingEditingVariant: null, styleVariantKeySet: new Set(styleVariantKeys), resolvedStyleColorCache: null, styleProbeHosts: null, THEME_BORDER_HIGHLIGHT: '__theme_border_highlight__', getActiveVariant() { return Settings.getBoardVariant(); }, getBoardVariant() { const mode = Conf['sfwNsfwMode']; if (mode === 'sfw') return 'sfw'; if (mode === 'nsfw') return 'nsfw'; if (!g.boardID) return 'sfw'; const boards = BoardConfig.boards || Conf['boardConfig']?.boards; const board = boards?.[g.boardID]; if (!board) return 'sfw'; return board.ws_board ? 'sfw' : 'nsfw'; }, variantKey(key, variant = Settings.getActiveVariant()) { return `${key} ${variant.toUpperCase()}`; }, styleConf(key, variant) { return Conf[Settings.variantKey(key, variant)]; }, resolveTextMode(value) { return value === 'auto' || value === 'manual' ? value : 'default'; }, styleKeyBase(key) { return key.replace(/ (SFW|NSFW)$/, ''); }, prepareDrag(e) { if (doc.classList.contains('xt-mobile')) { return; } const settingsWindow = $('#fourchanx-settings', Settings.dialog); const rect = settingsWindow.getBoundingClientRect(); settingsWindow.style.left = `${rect.left}px`; settingsWindow.style.top = `${rect.top}px`; settingsWindow.style.right = ''; settingsWindow.style.bottom = ''; settingsWindow.style.margin = '0'; settingsWindow.style.transform = 'none'; dragstart.call(this, e); }, init() { const link = $.el('a', { className: 'settings-link', title: `${meta.name} Settings`, href: '#' }); Icon.set(link, 'wrench', 'Settings'); $.on(link, 'click', e => { e.preventDefault(); Settings.open(); }); Header.addShortcut('settings', link, 820); const add = this.addSection; add('General', this.general); add('Styling', this.styling); add('Interface', this.interface); if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { add('Mobile', this.mobile); } add('Threads & Posts', this.threadsAndPosts); add('Media', this.media); add('Posting', this.posting); add('Filters', this.filter); add('Keybinds', this.keybinds); add('Advanced', this.advanced); add('All Settings', this.allSettings); $.on(d, 'AddSettingsSection', Settings.addSection); $.on(d, 'OpenSettings', e => Settings.open(e.detail)); $.on(d, 'CustomSiteThemeChanged', Settings.onSiteThemeChanged); $.get({ 'settings.highlightNext': false }, (items) => { Settings.highlightNext = !!items['settings.highlightNext']; d.documentElement.classList.toggle('highlight-next-global', Settings.highlightNext); }); if ((g.SITE.software === 'yotsuba') && Conf['Disable Native Extension']) { if ($.hasStorage) { $.global('disableNativeExtension'); } else { $.global('disableNativeExtensionNoStorage'); } } Settings.applyStylingVars(); }, externalStyleMarkers: ['#ch4SS', '#oneechan-options'], externalStyleLinks: ['#StyleChanLink', '#OneeChanLink'], detectExternalStyler() { const has = (sel) => !!d.querySelector(sel); if (!d.documentElement.classList.contains('oneechan') && !Settings.externalStyleMarkers.some(has)) { return null; } let name = 'A styling script'; if (has('#StyleChanLink')) name = 'StyleChan'; else if (has('#mascot-section') || has('#add-mascot') || has('#OneeChanLink')) name = 'OneeChan'; const settingsLink = Settings.externalStyleLinks.find(has) || null; return { name, settingsLink }; }, externalStylerActive() { return !!Settings.detectExternalStyler(); }, onExternalStylerReady(cb) { if (Settings.externalStylerActive()) { cb(true); return; } let settled = false; let obs = null; const finish = (present) => { if (settled) return; settled = true; obs?.disconnect(); cb(present); }; obs = new MutationObserver(() => { if (Settings.externalStylerActive()) finish(true); }); if (d.head) obs.observe(d.head, { childList: true }); const decide = () => window.setTimeout(() => finish(Settings.externalStylerActive()), 500); if (d.readyState === 'complete') { decide(); } else { $.on(window, 'load', decide); } }, openExternalStylerSettings() { const info = Settings.detectExternalStyler(); const link = info && info.settingsLink ? d.querySelector(info.settingsLink) : null; if (!link) return false; link.click(); return true; }, stylingSectionKeys: { siteStyle: 'stylingSectionSiteStyle', highlights: 'stylingSectionHighlights', scrollbarMarkers: 'stylingSectionScrollbarMarkers', textColors: 'stylingSectionTextColors', customCSS: 'stylingSectionCustomCSS', }, stylingSectionEnabled(id) { return Conf[Settings.stylingSectionKeys[id]] !== false; }, applyRecommendedStylingSections() { const recommended = { stylingSectionSiteStyle: false, stylingSectionTextColors: false, stylingSectionCustomCSS: false, stylingSectionHighlights: true, stylingSectionScrollbarMarkers: true, }; for (const [key, val] of Object.entries(recommended)) { Conf[key] = val; $.set(key, val); } }, initStylingSectionDefaults(present = Settings.externalStylerActive()) { if (present) { if (!Conf['stylingSectionsInitialized']) { Settings.applyRecommendedStylingSections(); Conf['stylingSectionsInitialized'] = true; $.set('stylingSectionsInitialized', true); } return; } const keys = Object.values(Settings.stylingSectionKeys); const dirty = Conf['stylingSectionsInitialized'] || keys.some(k => Conf[k] === false); if (!dirty) return; for (const k of keys) { if (Conf[k] !== true) { Conf[k] = true; $.set(k, true); } } Conf['stylingSectionsInitialized'] = false; $.set('stylingSectionsInitialized', false); }, setupStylingSectionToggles(section) { const details = $$('details[data-styling-section]', section); const entries = []; const syncOne = (detail, id, cb) => { const on = Settings.stylingSectionEnabled(id); cb.checked = on; detail.classList.toggle('styling-section-off', !on); }; for (const detail of details) { const id = detail.dataset.stylingSection; if (!id || !(id in Settings.stylingSectionKeys)) continue; const summary = $('summary', detail); if (!summary) continue; const label = $.el('label', { className: 'styling-section-toggle', title: 'Enable this styling section (off hands it to your styling script)', }); const cb = $.el('input', { type: 'checkbox' }); const boxIcon = $.el('span', { className: 'styling-section-toggle-box', 'aria-hidden': 'true' }); $.add(label, [cb, boxIcon]); $.on(label, 'click', e => e.stopPropagation()); $.on(cb, 'change', () => { const key = Settings.stylingSectionKeys[id]; Conf[key] = cb.checked; $.set(key, cb.checked); detail.classList.toggle('styling-section-off', !cb.checked); Settings.applyStylingSectionRuntime(); }); const titleWrap = $.el('span', { className: 'styling-section-summary-label' }); const titleText = $.el('span', { className: 'styling-section-summary-text' }); const summaryActions = []; while (summary.firstChild) { const child = summary.firstChild; if (child instanceof HTMLElement && child.matches('button, input, select, textarea, label, [data-summary-action]')) { summaryActions.push(child); summary.removeChild(child); } else { titleText.appendChild(child); } } $.add(titleWrap, [label, titleText]); summary.appendChild(titleWrap); for (const action of summaryActions) summary.appendChild(action); syncOne(detail, id, cb); entries.push({ detail, id, cb }); } return () => { for (const e of entries) syncOne(e.detail, e.id, e.cb); }; }, applyStylingSectionRuntime() { Settings.applyStylingVars(); $.event('RefreshScrollMarkers'); CustomCSS.update(); $.event('CustomSiteThemeChanged'); }, open(openSection) { let dialog, sectionToOpen; if (Settings.dialog) { return; } $.event('CloseMenu'); Settings.dialog = (dialog = $.el('div', { id: 'xt-settings-overlay' }, settingsHtml())); const settingsWindow = $('#fourchanx-settings', dialog); $.on($('.export', dialog), 'click', e => { e.preventDefault(); Settings.export(); Settings.closeFooterActions(); }); $.on($('.import', dialog), 'click', e => { e.preventDefault(); Settings.import.call(e.currentTarget); Settings.closeFooterActions(); }); $.on($('.reset', dialog), 'click', e => { e.preventDefault(); Settings.reset(); Settings.closeFooterActions(); }); $.on($('input[type=file]', dialog), 'change', Settings.onImport); $.on($('.settings-search input', dialog), 'input', Settings.onSearchInput); $.on($('.expand-all', dialog), 'click', e => { e.preventDefault(); Settings.toggleAllDetails(true); }); $.on($('.collapse-all', dialog), 'click', e => { e.preventDefault(); Settings.toggleAllDetails(false); }); $.on($('.accordion-toggle', dialog), 'click', e => { e.preventDefault(); Settings.toggleAccordion(); }); for (const btn of $$('.settings-nav-scroll', dialog)) { $.on(btn, 'click', e => { e.preventDefault(); e.stopPropagation(); Settings.scrollHorizontalNav(settingsWindow, btn.classList.contains('settings-nav-scroll-left') ? -1 : 1); }); $.on(btn, 'touchstart mousedown', e => e.stopPropagation()); } $.on($('.settings-nav-toggle', dialog), 'click', e => { e.preventDefault(); e.stopPropagation(); Settings.toggleCollapsedNav(); }); $.on($('.settings-footer-menu-toggle', dialog), 'click', e => { e.preventDefault(); e.stopPropagation(); Settings.toggleFooterActions(); }); $.on($('.move', settingsWindow), 'touchstart mousedown', Settings.prepareDrag); $.on(settingsWindow, 'mousedown', () => Settings.raiseToFront(settingsWindow)); $.on($('#settings-remember-layout', dialog), 'change', Settings.onRememberLayoutChange); $.on($('#settings-highlight-next', dialog), 'change', Settings.onHighlightNextChange); for (const actionEl of $$('.settings-titlebar-actions > *', settingsWindow)) { $.on(actionEl, 'touchstart mousedown', e => e.stopPropagation()); } const links = []; let defaultLink; for (const section of Settings.sections) { const link = $.el('a', { className: `tab-${section.hyphenatedTitle}`, textContent: section.title, href: '#' }); let downX = null, downY = null; $.on(link, 'mousedown', e => { downX = e.clientX; downY = e.clientY; }); $.on(link, 'click', e => { e.preventDefault(); if (downX !== null && downY !== null && (Math.abs(e.clientX - downX) > 4 || Math.abs(e.clientY - downY) > 4)) { downX = downY = null; return; } downX = downY = null; Settings.openSection.call(section); Settings.closeCollapsedNav(); }); links.push(link); if (!defaultLink && section.title === 'General') defaultLink = link; if (section.title === openSection || (['Filter', 'Filters', 'Simple Filters', 'Filtering'].includes(openSection) && section.title === 'Filters') || (openSection === 'Main' && section.title === 'General')) { sectionToOpen = link; } } $.add($('.sections-list', dialog), links); $.on($('.settings-search', dialog), 'touchstart mousedown', e => e.stopPropagation()); $.on($('.sections-list', dialog), 'touchstart mousedown', e => e.stopPropagation()); $.on($('.sections-list', dialog), 'scroll', () => Settings.updateHorizontalNavOverflow(settingsWindow)); Settings.enableHorizontalNavDrag($('.sections-list', dialog)); Settings.setNavLayout(settingsWindow, Conf['Settings Menu Layout']); const initialLink = sectionToOpen || defaultLink || links[0]; if (openSection !== 'none') { initialLink.click(); } Icon.set($('.close', dialog), 'xmark'); $.on($('.close', dialog), 'click', e => { e.preventDefault(); Settings.close(); }); $.on(window, 'beforeunload', Settings.close); $.on(dialog, 'mousedown touchstart', e => { const target = e.target; Settings.pointerDownInsideDialog = settingsWindow.contains(target); }); $.on(dialog, 'click', e => { if (e.target !== dialog) { return; } if (Settings.pointerDownInsideDialog) { Settings.pointerDownInsideDialog = false; return; } if (d.activeElement?.tagName === 'INPUT' || d.activeElement?.tagName === 'TEXTAREA') return; Settings.close(); }); $.on(settingsWindow, 'click', e => { if (Settings.onDescriptionLabelTap(e)) return; Settings.closeMobileDescription(); Settings.closeCollapsedNavForClick(e); Settings.closeFooterActionsForClick(e); e.stopPropagation(); }); $.add(d.body, dialog); d.documentElement.classList.add('xt-settings-open'); Settings.restoreWindowLayout(settingsWindow); Settings.watchSettingsNavWidth(settingsWindow); Settings.applyResponsiveNavLayout(settingsWindow); initialLink?.focus(); Settings.loadLayoutPrefs(); $.event('OpenSettings', null, dialog); }, close() { if (!Settings.dialog) { return; } d.activeElement?.blur(); Settings.easyFiltersFlush?.(); Settings.easyFiltersFlush = null; if (Settings.rememberLayout) { Settings.persistCurrentDetailsState(); const settingsWindow = $('#fourchanx-settings', Settings.dialog); if (settingsWindow && !doc.classList.contains('xt-mobile')) Settings.saveWindowLayout(settingsWindow); } Settings.closeImpExpPicker(); Settings.restoreAutoDescriptionTooltips(); Settings.closeFooterActions(); const searchInput = $('.settings-search input', Settings.dialog); if (searchInput) searchInput.value = ''; SearchHighlight.clear(SETTINGS_SEARCH_HL); Settings.settingsNavResizeObserver?.disconnect(); Settings.settingsNavResizeObserver = null; if (Settings.settingsNavResizeFallback) { $.off(window, 'resize', Settings.settingsNavResizeFallback); Settings.settingsNavResizeFallback = null; } $.rm(Settings.dialog); d.documentElement.classList.remove('xt-settings-open'); Settings.detached = Object.create(null); Settings.searchQuery = ''; Settings.searchTerms = []; Settings.activeSection = null; Settings.renderedSection = null; Settings.rememberLayout = false; Settings.savedWindowLayout = ''; Settings.detailsState = dict(); Settings.customCSSEditorThemeObserver?.disconnect(); Settings.customCSSEditorThemeObserver = null; if (Settings.siteStylePickerOutsideHandler) { d.removeEventListener('mousedown', Settings.siteStylePickerOutsideHandler, true); Settings.siteStylePickerOutsideHandler = null; } Settings.activeSiteStylePicker = null; Settings.resolvedStyleColorCache = null; delete Settings.dialog; Settings.stylingEditingVariant = null; }, toggleAllDetails(open) { if (!Settings.dialog) return; if (open && Settings.accordionMode) return; const section = $('section', Settings.dialog); if (!section) return; for (const details of $$('details', section)) { details.open = open; } if (Settings.rememberLayout) { Settings.persistCurrentDetailsState(); $.set('settings.detailsState', Settings.detailsState); } }, toggleAccordion() { Settings.setAccordionMode(!Settings.accordionMode); }, setAccordionMode(on) { Settings.accordionMode = on; $.set('settings.accordionMode', on); Settings.syncAccordionButton(); const section = Settings.dialog && $('section', Settings.dialog); if (section) { Settings.applyAccordionMode(section); if (!on) Settings.expandTopLevelPanels(section); } }, expandTopLevelPanels(section) { const blocks = $$('.settings-section-content', section); const containers = blocks.length ? blocks : [section]; for (const container of containers) { for (const el of [...container.children]) { if (el.tagName === 'DETAILS') el.open = true; } } if (Settings.rememberLayout) { Settings.persistCurrentDetailsState(); $.set('settings.detailsState', Settings.detailsState); } }, syncAccordionButton() { if (!Settings.dialog) return; const btn = $('.accordion-toggle', Settings.dialog); if (!btn) return; btn.classList.toggle('accordion-active', Settings.accordionMode); btn.setAttribute('aria-pressed', Settings.accordionMode ? 'true' : 'false'); const actions = $('.settings-titlebar-actions', Settings.dialog); if (actions) actions.classList.toggle('accordion-on', Settings.accordionMode); }, applyAccordionMode(section) { if (!section) return; const active = Settings.accordionMode && !Settings.searchQuery; const blocks = $$('.settings-section-content', section); const containers = blocks.length ? blocks : [section]; const allDetails = []; for (const container of containers) { for (const el of [...container.children]) { if (el.tagName === 'DETAILS') allDetails.push(el); } } if (!active) { for (const panel of allDetails) panel.removeAttribute('name'); return; } const panels = allDetails.filter(p => !p.hidden); let kept = false; for (const panel of panels) { if (panel.open && !kept) { kept = true; } else if (panel.open) { panel.open = false; } } if (!kept && panels.length) panels[0].open = true; for (const panel of panels) panel.setAttribute('name', 'xt-accordion'); for (const panel of allDetails) if (panel.hidden) panel.removeAttribute('name'); }, loadLayoutPrefs() { if (!Settings.dialog) return; $.get({ 'settings.rememberLayout': false, 'settings.highlightNext': false, 'settings.accordionMode': false, 'settings.windowLayout': '', 'settings.detailsState': dict(), }, (prefs) => { if (!Settings.dialog) return; Settings.rememberLayout = !!prefs['settings.rememberLayout']; Settings.highlightNext = !!prefs['settings.highlightNext']; Settings.accordionMode = !!prefs['settings.accordionMode']; Settings.syncAccordionButton(); const highlightToggle = $('#settings-highlight-next', Settings.dialog); if (highlightToggle) highlightToggle.checked = Settings.highlightNext; Settings.applyNextHighlight(); Settings.savedWindowLayout = typeof prefs['settings.windowLayout'] === 'string' ? prefs['settings.windowLayout'] : ''; const detailsState = prefs['settings.detailsState']; Settings.detailsState = (detailsState && typeof detailsState === 'object') ? detailsState : dict(); const toggle = $('#settings-remember-layout', Settings.dialog); if (toggle) toggle.checked = Settings.rememberLayout; const settingsWindow = $('#fourchanx-settings', Settings.dialog); if (settingsWindow && Settings.rememberLayout) { Settings.restoreWindowLayout(settingsWindow); } if (Settings.renderedSection) { const section = $('section', Settings.dialog); if (section) { Settings.decorateDetailsWithKeys(section, Settings.renderedSection); Settings.applyAccordionMode(section); } } }); }, nextRowForField(field) { const named = field.closest('[data-name]'); if (named && nextStatusOf(named.dataset.name || '')) return named; return field.closest('label') || named; }, nextBadgeTitle(summary) { const existing = summary.querySelector(':scope .styling-section-summary-text, :scope > .next-summary-title'); if (existing) return existing; const title = $.el('span', { className: 'next-summary-title' }); const isAside = (node) => node instanceof HTMLElement && node.matches('button, input, select, textarea, label, .styling-variant-badge, [data-summary-action]'); const anchor = [...summary.childNodes].find(isAside) || null; for (const node of [...summary.childNodes]) { if (!isAside(node)) title.appendChild(node); } summary.insertBefore(title, anchor); return title; }, tagNextSettings(root) { if (!root) return; for (const el of $$('[data-next-status]', root)) delete el.dataset.nextStatus; for (const el of $$('[data-name]', root)) { const status = nextStatusOf(el.dataset.name || ''); if (status) el.dataset.nextStatus = status; } for (const field of $$('[name]', root)) { const status = nextStatusOf(field.getAttribute('name') || ''); if (!status) continue; const row = Settings.nextRowForField(field); if (row && row.dataset.nextStatus !== 'added') row.dataset.nextStatus = status; } for (const el of $$('[data-next-manual-status]', root)) { const status = el.dataset.nextManualStatus; if (status !== 'added' && status !== 'changed') continue; const target = el.tagName === 'SUMMARY' ? Settings.nextBadgeTitle(el) : el; target.dataset.nextStatus = status; } for (const details of $$('details', root)) { const summary = details.querySelector(':scope > summary'); if (!summary) continue; const manualSection = details.dataset.nextSection; if (manualSection === 'added' || manualSection === 'changed') { for (const r of $$('[data-next-status]', details)) delete r.dataset.nextStatus; Settings.nextBadgeTitle(summary).dataset.nextStatus = manualSection; continue; } const rows = $$('[data-name]', details).filter(r => r.dataset.name); if (rows.length && rows.every(r => r.dataset.nextStatus === 'added')) { Settings.nextBadgeTitle(summary).dataset.nextStatus = 'added'; for (const r of rows) delete r.dataset.nextStatus; } } }, applyNextHighlight() { const settingsWindow = $('#fourchanx-settings', Settings.dialog || d); if (settingsWindow) settingsWindow.classList.toggle('highlight-next-settings', Settings.highlightNext); d.documentElement.classList.toggle('highlight-next-global', Settings.highlightNext); }, setHighlightNext(enabled) { Settings.highlightNext = enabled; $.set('settings.highlightNext', enabled); Settings.applyNextHighlight(); const toggle = Settings.dialog ? $('#settings-highlight-next', Settings.dialog) : null; if (toggle) toggle.checked = enabled; }, onHighlightNextChange() { Settings.setHighlightNext(this.checked); }, onRememberLayoutChange() { const enabled = this.checked; Settings.rememberLayout = enabled; $.set('settings.rememberLayout', enabled); if (!enabled) { Settings.savedWindowLayout = ''; Settings.detailsState = dict(); $.delete(['settings.windowLayout', 'settings.detailsState']); return; } if (Settings.dialog) { const settingsWindow = $('#fourchanx-settings', Settings.dialog); if (settingsWindow) Settings.saveWindowLayout(settingsWindow); Settings.persistCurrentDetailsState(); $.set({ 'settings.windowLayout': Settings.savedWindowLayout, 'settings.detailsState': Settings.detailsState }); } }, setNavLayout(settingsWindow, layout) { settingsWindow.dataset.navLayout = layout || 'vertical'; Settings.applyResponsiveNavLayout(settingsWindow); }, applyResponsiveNavLayout(settingsWindow) { const titlebar = $('.settings-titlebar', settingsWindow); const actions = $('.settings-titlebar-actions', settingsWindow); const navToggle = $('.settings-nav-toggle', settingsWindow); const navScrollLeft = $('.settings-nav-scroll-left', settingsWindow); const navScrollRight = $('.settings-nav-scroll-right', settingsWindow); const nav = $('.settings-body > nav', settingsWindow); const search = $('.settings-search', settingsWindow); const sectionsList = $('.sections-list', settingsWindow); if (!titlebar || !actions || !navToggle || !navScrollLeft || !navScrollRight || !nav || !search || !sectionsList) return; const layout = settingsWindow.dataset.navLayout || 'vertical'; const width = settingsWindow.getBoundingClientRect().width || doc.clientWidth; const collapsed = width <= SETTINGS_COLLAPSED_NAV_WIDTH; const compact = width <= SETTINGS_COMPACT_CONTENT_WIDTH; const wasCollapsed = settingsWindow.classList.contains('settings-nav-collapsed'); settingsWindow.classList.toggle('settings-compact-content', compact); settingsWindow.classList.toggle('settings-nav-collapsed', collapsed); if (collapsed && !wasCollapsed) Settings.autoEnableDescriptionTooltips(); if (!collapsed && wasCollapsed) Settings.restoreAutoDescriptionTooltips(); if (collapsed) { actions.insertBefore(search, navToggle.nextSibling); nav.appendChild(sectionsList); $.rmClass(settingsWindow, 'settings-nav-horizontal'); } else if (layout === 'horizontal') { titlebar.insertBefore(search, actions); titlebar.insertBefore(navScrollLeft, actions); titlebar.insertBefore(sectionsList, actions); titlebar.insertBefore(navScrollRight, actions); $.addClass(settingsWindow, 'settings-nav-horizontal'); } else { titlebar.insertBefore(search, actions); nav.appendChild(sectionsList); $.rmClass(settingsWindow, 'settings-nav-horizontal'); } if (!collapsed) Settings.closeCollapsedNav(settingsWindow); if (!compact) Settings.closeFooterActions(settingsWindow); Settings.syncCollapsedNavButton(settingsWindow); Settings.syncFooterActionsButton(settingsWindow); Settings.updateHorizontalNavOverflow(settingsWindow); Settings.applyDescriptionMode(); }, watchSettingsNavWidth(settingsWindow) { Settings.settingsNavResizeObserver?.disconnect(); if (Settings.settingsNavResizeFallback) { $.off(window, 'resize', Settings.settingsNavResizeFallback); Settings.settingsNavResizeFallback = null; } const update = () => Settings.applyResponsiveNavLayout(settingsWindow); if (typeof ResizeObserver !== 'undefined') { Settings.settingsNavResizeObserver = new ResizeObserver(update); Settings.settingsNavResizeObserver.observe(settingsWindow); return; } Settings.settingsNavResizeFallback = update; $.on(window, 'resize', update); }, toggleCollapsedNav() { const settingsWindow = Settings.dialog && $('#fourchanx-settings', Settings.dialog); if (!settingsWindow || !settingsWindow.classList.contains('settings-nav-collapsed')) return; settingsWindow.classList.toggle('settings-nav-open'); Settings.syncCollapsedNavButton(settingsWindow); }, closeCollapsedNav(settingsWindow) { settingsWindow = settingsWindow || (Settings.dialog && $('#fourchanx-settings', Settings.dialog)); if (!settingsWindow) return; settingsWindow.classList.remove('settings-nav-open'); Settings.syncCollapsedNavButton(settingsWindow); }, closeCollapsedNavForClick(e) { const settingsWindow = Settings.dialog && $('#fourchanx-settings', Settings.dialog); const target = e.target; if (!settingsWindow || !target || !settingsWindow.classList.contains('settings-nav-collapsed')) return; if (target.closest('.settings-nav-toggle') || target.closest('.settings-body > nav')) return; Settings.closeCollapsedNav(settingsWindow); }, syncCollapsedNavButton(settingsWindow) { const toggle = $('.settings-nav-toggle', settingsWindow); if (!toggle) return; const expanded = settingsWindow.classList.contains('settings-nav-open'); toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); }, toggleFooterActions() { const settingsWindow = Settings.dialog && $('#fourchanx-settings', Settings.dialog); if (!settingsWindow || !settingsWindow.classList.contains('settings-compact-content')) return; const menu = $('.settings-footer-menu', settingsWindow); if (!menu) return; menu.classList.toggle('settings-footer-menu-open'); Settings.syncFooterActionsButton(settingsWindow); }, closeFooterActions(settingsWindow) { settingsWindow = settingsWindow || (Settings.dialog && $('#fourchanx-settings', Settings.dialog)); if (!settingsWindow) return; $('.settings-footer-menu', settingsWindow)?.classList.remove('settings-footer-menu-open'); Settings.syncFooterActionsButton(settingsWindow); }, closeFooterActionsForClick(e) { const settingsWindow = Settings.dialog && $('#fourchanx-settings', Settings.dialog); const target = e.target; if (!settingsWindow || !target) return; if (target.closest('.settings-footer-menu')) return; Settings.closeFooterActions(settingsWindow); }, syncFooterActionsButton(settingsWindow) { const menu = $('.settings-footer-menu', settingsWindow); const toggle = $('.settings-footer-menu-toggle', settingsWindow); if (!menu || !toggle) return; const expanded = menu.classList.contains('settings-footer-menu-open'); toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); }, updateHorizontalNavOverflow(settingsWindow) { const sectionsList = $('.settings-titlebar > .sections-list', settingsWindow); if (!sectionsList || !settingsWindow.classList.contains('settings-nav-horizontal')) { settingsWindow.classList.remove('settings-nav-overflow-left', 'settings-nav-overflow-right'); return; } settingsWindow.classList.remove('settings-nav-overflow-left', 'settings-nav-overflow-right'); const maxScroll = sectionsList.scrollWidth - sectionsList.clientWidth; settingsWindow.classList.toggle('settings-nav-overflow-left', sectionsList.scrollLeft > 1); settingsWindow.classList.toggle('settings-nav-overflow-right', maxScroll > 1 && sectionsList.scrollLeft < maxScroll - 1); }, scrollHorizontalNav(settingsWindow, direction = 1) { const sectionsList = $('.settings-titlebar > .sections-list', settingsWindow); if (!sectionsList) return; sectionsList.scrollBy({ left: direction * Math.max(120, Math.round(sectionsList.clientWidth * 0.7)), behavior: 'smooth', }); window.setTimeout(() => Settings.updateHorizontalNavOverflow(settingsWindow), 250); }, enableHorizontalNavDrag(sectionsList) { let pointerId = null; let startX = 0; let startScrollLeft = 0; let moved = false; let captured = false; sectionsList.addEventListener('click', e => { if (sectionsList.dataset.suppressClick !== 'true') return; e.preventDefault(); e.stopPropagation(); }, true); $.on(sectionsList, 'pointerdown', e => { const pe = e; const settingsWindow = Settings.dialog && $('#fourchanx-settings', Settings.dialog); if (!settingsWindow?.classList.contains('settings-nav-horizontal')) return; pointerId = pe.pointerId; startX = pe.clientX; startScrollLeft = sectionsList.scrollLeft; moved = false; captured = false; }); $.on(sectionsList, 'pointermove', e => { const pe = e; if (pointerId !== pe.pointerId) return; const dx = pe.clientX - startX; if (!moved && Math.abs(dx) > 3) { moved = true; captured = true; sectionsList.classList.add('sections-list-dragging'); sectionsList.setPointerCapture?.(pe.pointerId); } if (!moved) return; pe.preventDefault(); sectionsList.scrollLeft = startScrollLeft - dx; const settingsWindow = Settings.dialog && $('#fourchanx-settings', Settings.dialog); if (settingsWindow) Settings.updateHorizontalNavOverflow(settingsWindow); }); const endDrag = (e) => { const pe = e; if (pointerId !== pe.pointerId) return; if (captured) sectionsList.releasePointerCapture?.(pe.pointerId); pointerId = null; captured = false; sectionsList.classList.remove('sections-list-dragging'); if (!moved) return; sectionsList.dataset.suppressClick = 'true'; window.setTimeout(() => { delete sectionsList.dataset.suppressClick; }, 0); }; $.on(sectionsList, 'pointerup pointercancel', endDrag); }, restoreWindowLayout(settingsWindow) { if (doc.classList.contains('xt-mobile')) { return; } if (Settings.rememberLayout && Settings.savedWindowLayout) { settingsWindow.style.cssText += `;${Settings.savedWindowLayout}`; return; } if (!settingsWindow.style.left && !settingsWindow.style.right && !settingsWindow.style.top && !settingsWindow.style.bottom) { const rect = settingsWindow.getBoundingClientRect(); settingsWindow.style.left = `${Math.max(0, (doc.clientWidth - rect.width) / 2)}px`; settingsWindow.style.top = `${Math.max(0, (doc.clientHeight - rect.height) / 2)}px`; } }, saveWindowLayout(settingsWindow) { const style = settingsWindow.style; let layout = ''; if (style.left) layout += `left:${style.left};`; if (style.right) layout += `right:${style.right};`; if (style.top) layout += `top:${style.top};`; if (style.bottom) layout += `bottom:${style.bottom};`; if (style.width) layout += `width:${style.width};`; if (style.height) layout += `height:${style.height};`; Settings.savedWindowLayout = layout; $.set('settings.windowLayout', layout); }, detailStateScope(root, sectionInfo) { let sectionTitle = sectionInfo?.title || ''; if (sectionTitle === 'All Settings') { const block = root.closest('.settings-section-block'); sectionTitle = block ? $('.settings-section-header', block)?.textContent?.trim() || '' : ''; } return `section:${sectionTitle}`; }, detailsStateKey(details, sectionInfo) { const root = details.parentElement; if (!root) return ''; const scope = Settings.detailStateScope(root, sectionInfo); const summary = details.querySelector('summary')?.textContent?.trim() || ''; const peers = $$('details', root) .filter(peer => (peer.querySelector('summary')?.textContent?.trim() || '') === summary); const index = Math.max(0, peers.indexOf(details)); return `${scope}|${summary}|${index}`; }, decorateDetailsWithKeys(sectionRoot, sectionInfo, applyRememberedState = true) { for (const details of $$('details', sectionRoot)) { const shouldRememberState = details.dataset.rememberLayout !== 'false'; const key = Settings.detailsStateKey(details, sectionInfo); if (!key) continue; details.dataset.detailsStateKey = key; if (applyRememberedState && shouldRememberState && Settings.rememberLayout && Object.prototype.hasOwnProperty.call(Settings.detailsState, key)) { details.open = !!Settings.detailsState[key]; } if (details._detailsStateBound) continue; details._detailsStateBound = true; $.on(details, 'toggle', function () { if (!Settings.rememberLayout || !shouldRememberState) return; const stateKey = this.dataset.detailsStateKey; if (!stateKey) return; Settings.detailsState[stateKey] = this.open; $.set('settings.detailsState', Settings.detailsState); }); } }, persistCurrentDetailsState() { if (!Settings.dialog || !Settings.renderedSection) return; const section = $('section', Settings.dialog); if (!section) return; Settings.decorateDetailsWithKeys(section, Settings.renderedSection, false); for (const details of $$('details', section)) { if (details.dataset.rememberLayout === 'false') continue; const key = details.dataset.detailsStateKey; if (!key) continue; Settings.detailsState[key] = details.open; } }, getActiveSection() { const selectedTab = $('.tab-selected', Settings.dialog); if (!selectedTab) return null; for (const section of Settings.sections) { if (selectedTab.classList.contains(`tab-${section.hyphenatedTitle}`)) return section; } return null; }, onSearchInput() { Settings.searchQuery = this.value.toLowerCase().trim(); Settings.searchTerms = Settings.searchQuery ? Settings.searchQuery.split(/\s+/) : []; if (Settings.searchQuery) { Settings.ensureAllSettingsRendered(); } else { Settings.renderActiveSection(); } Settings.applySearch(); }, matchesQuery(haystack) { if (!Settings.searchTerms.length) return false; const text = haystack.toLowerCase(); return Settings.searchTerms.every((term) => text.indexOf(term) >= 0); }, applySearch() { if (!Settings.dialog) return; const query = Settings.searchQuery; const win = $('#fourchanx-settings', Settings.dialog); win.classList.toggle('settings-searching', !!query); if (query) Settings.closeMobileDescription(); const section = $('section', Settings.dialog); if (!section) return; for (const el of $$('.settings-search-hidden', section)) { $.rmClass(el, 'settings-search-hidden'); } for (const el of $$('.settings-search-keyword-match', section)) { $.rmClass(el, 'settings-search-keyword-match'); } Settings.highlightSettingRow(section, query); if (!query) return; for (const el of $$('div[data-name], tr[data-name], details, .settings-group-heading, table, thead, tbody, summary, h4, .settings-section-block', section)) { $.addClass(el, 'settings-search-hidden'); } for (const row of $$('div[data-name], tr[data-name]', section)) { const settingTitle = `${row.dataset.settingTitle || ''}`; const settingName = `${row.dataset.name || ''}`; const fullText = `${settingName} ${settingTitle} ${row.dataset.settingDescription || ''} ${row.textContent || ''}`; if (!Settings.matchesQuery(fullText)) continue; const rowEl = row; Settings.revealSearchMatch(rowEl, section); rowEl.classList.toggle('settings-search-keyword-match', !Settings.hasVisibleMatch(rowEl)); const titleMatched = Settings.matchesQuery(`${settingName} ${settingTitle}`); if (!titleMatched) continue; for (const sublist of $$('.suboption-list', rowEl)) { for (const rider of $$('div[data-name], tr[data-name]', sublist)) { Settings.revealSearchMatch(rider, section); } } } for (const el of $$('summary, th, h4', section)) { if (!Settings.matchesQuery(el.textContent || '')) continue; Settings.revealSearchMatch(el, section); } for (const heading of $$('.settings-section-header', section)) { if (!Settings.matchesSectionTitle(heading.textContent || '', query)) continue; const block = heading.closest('.settings-section-block'); if (!block) continue; for (const el of $$('.settings-search-hidden', block)) { $.rmClass(el, 'settings-search-hidden'); } Settings.revealSearchMatch(block, section); } }, matchesSectionTitle(text, _query) { if (!text || !Settings.searchTerms.length) return false; return Settings.searchTerms.every((term) => RegExp(`\\b${Settings.escapeRegExp(term)}\\b`, 'i').test(text)); }, revealSearchMatch(node, root) { let cur = node; while (cur && cur !== root) { cur.classList.remove('settings-search-hidden'); if (cur.tagName === 'DETAILS') { const summary = cur.firstElementChild; if (summary?.tagName === 'SUMMARY') { summary.classList.remove('settings-search-hidden'); } } else if (cur.tagName === 'TABLE') { for (const child of cur.children) { if (child.tagName === 'THEAD' || child.tagName === 'TBODY') { child.classList.remove('settings-search-hidden'); } } } cur = cur.parentElement; } }, highlightableEls(root) { return $$(SEARCH_HIGHLIGHT_SELECTOR, root).filter(el => !(el.tagName === 'SUMMARY' && el.querySelector('.styling-section-summary-text, .next-summary-title'))); }, hasVisibleMatch(rowEl) { if (!Settings.searchTerms.length) return false; return Settings.highlightableEls(rowEl).some((el) => { const text = (el.textContent || '').toLowerCase(); return Settings.searchTerms.some((term) => text.indexOf(term) >= 0); }); }, highlightSettingRow(root, _query) { const els = Settings.highlightableEls(root); if (SearchHighlight.supported) { SearchHighlight.apply(SETTINGS_SEARCH_HL, els, Settings.searchTerms); return; } const rx = Settings.searchTerms.length ? RegExp(`(${Settings.searchTerms.map((t) => Settings.escapeRegExp(t)).join('|')})`, 'ig') : null; for (const el of els) { const source = el.dataset.rawText ?? el.textContent ?? ''; el.dataset.rawText = source; if (rx) { el.innerHTML = `${source.replace(rx, '$1')}`; } else { el.textContent = source; } } }, escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }, sections: [], addSection(title, open) { if (typeof title !== 'string') { ({ title, open } = title.detail); } const hyphenatedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); Settings.sections.push({ title, hyphenatedTitle, open }); }, openSection() { Settings.activeSection = this; Settings.selectSectionTab(this); if (Settings.searchQuery && this.title !== 'All Settings') { Settings.ensureAllSettingsRendered(); Settings.applySearch(); return; } Settings.renderSection(this); }, selectSectionTab(sectionInfo) { let selected; if (selected = $('.tab-selected', Settings.dialog)) { $.rmClass(selected, 'tab-selected'); } $.addClass($(`.tab-${sectionInfo.hyphenatedTitle}`, Settings.dialog), 'tab-selected'); }, getAllSettingsSection() { return Settings.sections.find((section) => section.title === 'All Settings') || null; }, ensureAllSettingsRendered() { const allSettingsSection = Settings.getAllSettingsSection(); if (!allSettingsSection) return; if (Settings.renderedSection === allSettingsSection) return; Settings.renderSection(allSettingsSection); }, ensureAutosaveTextareaShell(textarea) { let shell = textarea.parentElement; if (!shell || !shell.classList.contains('settings-textarea-shell')) { shell = $.el('div', { className: 'settings-textarea-shell' }); textarea.parentNode.insertBefore(shell, textarea); $.add(shell, textarea); } if (!$('.settings-textarea-saved', shell)) { const badge = $.el('div', { className: 'settings-textarea-saved', textContent: 'Saved' }); badge.setAttribute('aria-hidden', 'true'); $.add(shell, badge); } return shell; }, flashAutosaveTextareaSaved(textarea) { const shell = textarea.parentElement; const badge = shell?.classList.contains('settings-textarea-shell') ? $('.settings-textarea-saved', shell) : null; if (!badge) return; badge.dataset.show = 'true'; const timer = Settings.textareaSavedFlashTimers.get(textarea); if (timer) clearTimeout(timer); Settings.textareaSavedFlashTimers.set(textarea, window.setTimeout(() => { badge.dataset.show = 'false'; Settings.textareaSavedFlashTimers.delete(textarea); }, 900)); }, bindAutosaveTextareaSaveKey(textarea) { if (textarea.dataset.autosaveSaveKeyBound === 'true') return; textarea.dataset.autosaveSaveKeyBound = 'true'; $.on(textarea, 'keydown', (e) => { if (!(e.ctrlKey || e.metaKey) || e.altKey || (e.key !== 's' && e.key !== 'S')) return; e.preventDefault(); e.stopPropagation(); $.event('change', null, textarea); }); }, bindAutosaveTextareaSavedFlash(textarea) { if (textarea.dataset.autosaveSavedFlashBound === 'true') return; textarea.dataset.autosaveSavedFlashBound = 'true'; $.on(textarea, 'change', () => Settings.flashAutosaveTextareaSaved(textarea)); }, prepareAutosaveTextarea(textarea) { if (!textarea || textarea.classList.contains('custom-css-textarea')) return; const shell = Settings.ensureAutosaveTextareaShell(textarea); Settings.bindAutosaveTextareaSaveKey(textarea); Settings.bindAutosaveTextareaSavedFlash(textarea); const existing = shell.nextElementSibling; if (existing && existing.classList.contains('settings-textarea-resizer')) return; $.addClass(textarea, 'has-custom-resizer'); const handle = $.el('div', { className: 'settings-textarea-resizer', title: 'Drag to resize' }); $.on(handle, 'pointerdown', (e) => { e.preventDefault(); const startY = e.clientY; const startHeight = textarea.offsetHeight; $.addClass(handle, 'dragging'); const onMove = (ev) => { textarea.style.height = `${Math.max(28, startHeight + (ev.clientY - startY))}px`; }; const onUp = () => { $.rmClass(handle, 'dragging'); $.off(d, 'pointermove', onMove); $.off(d, 'pointerup', onUp); }; $.on(d, 'pointermove', onMove); $.on(d, 'pointerup', onUp); }); $.after(shell, handle); }, attachTextareaResizers(section) { for (const ta of $$('textarea:not(.custom-css-textarea)', section)) { if (ta.hidden) continue; Settings.prepareAutosaveTextarea(ta); } }, renderActiveSection() { const section = Settings.activeSection || Settings.getActiveSection() || Settings.sections[0]; if (!section) return; Settings.renderSection(section); }, renderSection(sectionInfo) { const section = $('section', Settings.dialog); if (!section) return; const leavingStyling = Settings.renderedSection && Settings.renderedSection.hyphenatedTitle === 'styling' && sectionInfo.hyphenatedTitle !== 'styling'; $.rmAll(section); section.className = `section-${sectionInfo.hyphenatedTitle}`; sectionInfo.open(section, g); Settings.attachTextareaResizers(section); Settings.decorateDetailsWithKeys(section, sectionInfo); Settings.applyAccordionMode(section); Settings.tagNextSettings(section); section.scrollTop = 0; Settings.renderedSection = sectionInfo; Settings.applyDescriptionMode(section); Settings.applySearch(); $.event('OpenSettings', null, section); if (leavingStyling) Settings.stylingEditingVariant = null; }, allSettings(section) { for (const sectionInfo of Settings.sections) { if (sectionInfo.title === 'All Settings') continue; const block = $.el('div', { className: `settings-section-block section-${sectionInfo.hyphenatedTitle}`, }); const heading = $.el('h3', { className: 'settings-section-header', textContent: sectionInfo.title, }); const content = $.el('div', { className: 'settings-section-content' }); $.add(block, [heading, content]); $.add(section, block); sectionInfo.open(content, g); } }, warnings: { localStorage(cb) { if ($.cantSync) { const why = $.cantSet ? 'save your settings' : 'synchronize settings between tabs'; cb($.el('li', { textContent: `\ ${meta.name} needs local storage to ${why}. Enable it on boards.${location.hostname.split('.')[1]}.org in your browser's privacy settings (may be listed as part of "local data" or "cookies").\ ` })); } }, ads(cb) { $.onExists(doc, '.adg-rects > .desktop', ad => $.onExists(ad, 'iframe', function () { const url = Redirect.to('thread', { boardID: 'qa', threadID: 362590 }); cb($.el('li', h(hFragment, null, "To protect yourself from ", h("a", { href: url, target: "_blank" }, "malicious ads"), ", you should ", h("a", { href: "https://github.com/gorhill/uBlock#ublock-origin", target: "_blank" }, "block ads"), " on 4chan."))); })); } }, getMainSettingLookup() { const lookup = dict(); for (const keyFS in Config.main) { const obj = Config.main[keyFS]; for (const key in obj) { const arr = obj[key]; if (Array.isArray(arr)) lookup[key] = arr; } } return lookup; }, descriptionsAsTooltips() { return Conf['Settings Descriptions as Tooltips'] === true; }, useDescriptionTooltips(_settingsWindow) { return Settings.descriptionsAsTooltips(); }, autoEnableDescriptionTooltips() { if (!Settings.descriptionTooltipAutoActive) { Settings.descriptionTooltipAutoPrevious = Settings.descriptionsAsTooltips(); } Settings.descriptionTooltipAutoActive = true; Conf['Settings Descriptions as Tooltips'] = true; Settings.syncDescriptionTooltipInputs(true); }, restoreAutoDescriptionTooltips() { if (!Settings.descriptionTooltipAutoActive) return; const previous = Settings.descriptionTooltipAutoPrevious === true; Conf['Settings Descriptions as Tooltips'] = previous; Settings.descriptionTooltipAutoActive = false; Settings.descriptionTooltipAutoPrevious = null; Settings.syncDescriptionTooltipInputs(previous); }, onDescriptionTooltipSettingChange(input) { Settings.descriptionTooltipAutoActive = false; Settings.descriptionTooltipAutoPrevious = null; Settings.syncDescriptionTooltipInputs(input.checked); Settings.applyDescriptionMode(); }, syncDescriptionTooltipInputs(enabled = Settings.descriptionsAsTooltips()) { if (!Settings.dialog) return; for (const input of $$('input[name="Settings Descriptions as Tooltips"]', Settings.dialog)) { input.checked = enabled; const row = input.closest('[data-name="Settings Descriptions as Tooltips"]'); if (row) row.dataset.checked = String(enabled); } }, hasTouchDescriptionPointer() { return typeof window.matchMedia === 'function' && window.matchMedia('(hover: none) and (pointer: coarse)').matches; }, isTouchDescriptionMode(settingsWindowArg) { const settingsWindow = arguments.length === 0 ? (Settings.dialog && $('#fourchanx-settings', Settings.dialog)) : settingsWindowArg; return !!settingsWindow?.classList.contains('settings-nav-collapsed') && Settings.hasTouchDescriptionPointer(); }, applyDescriptionMode(rootArg) { const root = arguments.length === 0 ? (Settings.dialog || d) : rootArg; const settingsWindow = $('#fourchanx-settings', Settings.dialog || d); const useTooltips = Settings.useDescriptionTooltips(settingsWindow); const touchDescriptions = Settings.isTouchDescriptionMode(settingsWindow); if (settingsWindow) { settingsWindow.classList.toggle('settings-description-tooltips', useTooltips); settingsWindow.classList.toggle('settings-touch-descriptions', touchDescriptions); } Settings.syncDescriptionTooltipInputs(); if (!useTooltips || !touchDescriptions) Settings.closeMobileDescription(); for (const row of $$('[data-setting-description]', root)) { const el = row; const description = el.dataset.settingDescription || ''; if (useTooltips && description) { el.title = description; } else { el.removeAttribute('title'); } } }, registerSettingDescription(row, description) { row.dataset.settingDescription = description; if (Settings.useDescriptionTooltips() && description) { row.title = description; } else { row.removeAttribute('title'); } }, onDescriptionLabelTap(e) { const settingsWindow = Settings.dialog && $('#fourchanx-settings', Settings.dialog); const target = e.target; if (!settingsWindow || !target || !Settings.isTouchDescriptionMode(settingsWindow) || !settingsWindow.classList.contains('settings-description-tooltips') || settingsWindow.classList.contains('settings-searching')) { return false; } if (target.closest('input, select, textarea, button, a, .settings-mobile-description')) return false; const row = target.closest('[data-setting-description]'); if (!row || !row.dataset.settingDescription) return false; const trigger = target.closest('.setting-title, label'); if (!trigger || !row.contains(trigger)) return false; e.preventDefault(); e.stopPropagation(); Settings.toggleMobileDescription(row); return true; }, toggleMobileDescription(row) { const wasOpen = row.classList.contains('settings-mobile-description-open'); Settings.closeMobileDescription(row); if (wasOpen) return; const description = row.dataset.settingDescription || ''; if (!description) return; const bubble = $.el('div', { className: 'settings-mobile-description', textContent: description, }); const label = $('label', row); if (label?.parentElement === row) { label.insertAdjacentElement('afterend', bubble); } else { $.add(row, bubble); } row.classList.add('settings-mobile-description-open'); }, closeMobileDescription(except) { if (!Settings.dialog) return; for (const bubble of $$('.settings-mobile-description', Settings.dialog)) { if (except && except.contains(bubble)) continue; $.rm(bubble); } for (const row of $$('.settings-mobile-description-open', Settings.dialog)) { if (row !== except) row.classList.remove('settings-mobile-description-open'); } }, descriptionSpan(text) { const outer = $.el('span', { className: 'description' }); if (text) { $.add(outer, [ $.tn(': '), $.el('span', { className: 'setting-description', textContent: text }), ]); } return outer; }, addCheckboxes(root, obj, items, inputs, includeSetting = (_key) => true) { const containers = [root]; let count = 0; for (const key in obj) { const arr = obj[key]; if (!(arr instanceof Array)) continue; if (!includeSetting(key)) continue; const description = arr[1] || ''; const settingTitle = { 'Thread Watcher Attach Controls': 'Attachment Controls', }[key] || key; const div = $.el('div', { innerHTML: `: ${description}` }); div.dataset.name = key; div.dataset.settingTitle = settingTitle; Settings.registerSettingDescription(div, description); const input = $('input', div); $.on(input, 'change', $.cb.checked); $.on(input, 'change', function () { this.parentNode.parentNode.dataset.checked = this.checked; }); if (key === 'Settings Descriptions as Tooltips') { $.on(input, 'change', () => Settings.onDescriptionTooltipSettingChange(input)); } if (key === 'Comment Preview') { $.on(input, 'change', () => $.event('QRCommentPreviewChanged')); } if (key === 'Thread Watcher Attach Controls') { $.on(input, 'change', () => { const TW = ThreadWatcher; if (TW && TW.applyAttachControlsSetting) { TW.applyAttachControlsSetting(input.checked); } }); } items[key] = Conf[key]; inputs[key] = input; const level = arr[2] || 0; if (containers.length <= level) { const container = $.el('div', { className: 'suboption-list' }); $.add(containers[containers.length - 1].lastElementChild, container); containers[level] = container; } else if (containers.length > (level + 1)) { containers.splice(level + 1, containers.length - (level + 1)); } $.add(containers[level], div); count++; } return count; }, selectGroup(obj, keys, baseLevel = 0) { const group = dict(); for (const key of keys) { const arr = obj[key]; if (!arr) continue; const [defaultValue, description, level] = arr; const adjustedLevel = Math.max(0, (level || 0) - baseLevel); group[key] = [defaultValue, description, adjustedLevel]; } return group; }, renderMainGroups(section, options) { const { categories, includeWarnings, includeJSONIndex, includeHiddenCount, hideLegendFor = [], includeSetting = (_key) => true } = options; if (includeWarnings) { const warnings = $.el('details', { hidden: true, open: true }, { innerHTML: 'Warnings
    ' }); const addWarning = function (item) { $.add($('ul', warnings), item); warnings.hidden = false; }; for (const key in Settings.warnings) { Settings.warnings[key](addWarning); } $.add(section, warnings); } const items = dict(); const inputs = dict(); for (const cat of categories) { let keyFS, subgroups; if (typeof cat === 'string') { keyFS = cat; subgroups = null; } else { keyFS = cat.name; subgroups = cat.subgroups; } const obj = Config.main[keyFS]; if (!obj) continue; if (subgroups) { for (const [legendTitle, keys, baseLevel] of subgroups) { const fs = $.el('details', { open: true }, { innerHTML: `${legendTitle}` }); const group = Settings.selectGroup(obj, keys, baseLevel || 0); if (!Settings.addCheckboxes(fs, group, items, inputs, includeSetting)) continue; if (legendTitle === 'Captcha') { $.add(fs, $.el('p', { innerHTML: `For more info on captcha options and issues, see the captcha FAQ.` })); } $.add(section, fs); } continue; } const legendTitle = keyFS === 'Filtering' ? 'Content Controls' : keyFS; let fs; if (hideLegendFor.includes(keyFS)) { fs = $.el('div'); } else { fs = $.el('details', { open: true }, { innerHTML: `${legendTitle}` }); } if (!Settings.addCheckboxes(fs, obj, items, inputs, includeSetting)) continue; if (keyFS === 'Posting and Captchas') { $.add(fs, $.el('p', { innerHTML: `For more info on captcha options and issues, see the captcha FAQ.` })); } $.add(section, fs); } if (includeJSONIndex) { const root = $('div[data-name="JSON Index"] > .suboption-list', section); if (root) Settings.addCheckboxes(root, Config.Index, items, inputs); } $.get(items, function (items) { for (const key in items) { const val = items[key]; if (!inputs[key]) continue; inputs[key].checked = val; inputs[key].parentNode.parentNode.dataset.checked = val; } if (inputs['Settings Descriptions as Tooltips']) Settings.syncDescriptionTooltipInputs(); }); if (!includeHiddenCount) return; const div = $.el('div', { innerHTML: ': Clear manually-hidden threads and posts on all boards. Reload the page to apply.' }); const button = $('button', div); $.get({ hiddenThreads: dict(), hiddenPosts: dict() }, function ({ hiddenThreads, hiddenPosts }) { let board, ID, site, thread; let hiddenNum = 0; for (ID in hiddenThreads) { site = hiddenThreads[ID]; if (ID !== 'boards') { for (ID in site.boards) { board = site.boards[ID]; hiddenNum += Object.keys(board).length; } } } for (ID in hiddenThreads.boards) { board = hiddenThreads.boards[ID]; hiddenNum += Object.keys(board).length; } for (ID in hiddenPosts) { site = hiddenPosts[ID]; if (ID !== 'boards') { for (ID in site.boards) { board = site.boards[ID]; for (ID in board) { thread = board[ID]; hiddenNum += Object.keys(thread).length; } } } } for (ID in hiddenPosts.boards) { board = hiddenPosts.boards[ID]; for (ID in board) { thread = board[ID]; hiddenNum += Object.keys(thread).length; } } button.textContent = `Hidden: ${hiddenNum}`; }); $.on(button, 'click', function () { this.textContent = 'Hidden: 0'; $.get('hiddenThreads', dict(), function ({ hiddenThreads }) { if ($.hasStorage && (g.SITE.software === 'yotsuba')) { let boardID; for (boardID in hiddenThreads['4chan.org']?.boards) { localStorage.removeItem(`4chan-hide-t-${boardID}`); } for (boardID in hiddenThreads.boards) { localStorage.removeItem(`4chan-hide-t-${boardID}`); } } $.delete(['hiddenThreads', 'hiddenPosts']); }); }); const stubs = $('input[name="Stubs"]', section); if (stubs) stubs.closest('details').insertAdjacentElement('beforeend', div); else $.add(section, div); }, addSelectFieldset(section, title, rows) { const fs = $.el('details', { open: true }, { innerHTML: `${title}` }); const inputs = Settings.addSelectRows(fs, rows); $.add(section, fs); return inputs; }, addSelectRows(root, rows) { const items = dict(); const inputs = dict(); for (const row of rows) { const div = $.el('div'); div.dataset.name = row.name; div.dataset.settingTitle = row.label; Settings.registerSettingDescription(div, row.description || ''); const label = $.el('label'); const select = $.el('select', { name: row.name }); for (const option of row.options) { select.appendChild($.el('option', { value: option[0], textContent: option[1] })); } $.add(label, [ $.el('span', { className: 'setting-title', textContent: `${row.label}: ` }), select ]); $.add(div, [ label, Settings.descriptionSpan(row.description || '') ]); $.on(select, 'change', $.cb.value); if (row.name === 'Comment Preview Position' || row.name === 'Comment Preview Inline Behavior') { $.on(select, 'change', () => $.event('QRCommentPreviewChanged', null)); } if (row.name === 'RelativeTime') { $.on(select, 'change', () => $.event('RelativePostDatesChanged', null)); } items[row.name] = Conf[row.name]; inputs[row.name] = select; $.add(root, div); } $.get(items, function (items) { for (const key in items) { inputs[key].value = items[key]; } }); return inputs; }, general(section) { Settings.renderMainGroups(section, { categories: [{ name: 'Miscellaneous', subgroups: [ ['System', ['JSON Index', `Use ${meta.name} Catalog`, 'Index Refresh Notifications', 'Open Threads in New Tab', 'External Catalog', '404 Redirect', 'Archive Report', 'Exempt Archives from Encryption', 'Show Updated Notifications']], ['Compatibility', ['Disable Native Extension', 'Enable Native Flash Embedding']] ] }], includeWarnings: true, includeJSONIndex: true }); }, interface(section) { const items = dict(); const inputs = dict(); const fsNav = $.el('details', { open: true }, { innerHTML: 'Custom Board Navigation' }); const navContent = $.el('div', { className: 'boardnav-detachable', innerHTML: '
    ' + 'New lines will be converted into spaces.

    ' + '
    ' + 'Syntax guide' + '
    In the following examples for /g/, g can be changed to a different board ID (a, b, etc...), the current board (current), or the Twitter link (@).
    ' + '
    Board link: g
    ' + '
    Archive link: g-archive
    ' + '
    Internal archive link: g-expired
    ' + '
    Title link: g-title
    ' + '
    Board link (Replace with title when on that board): g-replace
    ' + '
    Full text link: g-full
    ' + '
    Custom text link: g-text:"Install Gentoo"
    ' + '
    Index-only link: g-index
    ' + '
    Catalog-only link: g-catalog
    ' + '
    Index mode: g-mode:"infinite scrolling"
    ' + '
    Index sort: g-sort:"creation date rev"
    ' + '
    External link: external-text:"Google","http://www.google.com"
    ' + '
    Open in new tab: g-nt
    ' + '
    Combinations are possible: g-index-text:"Technology Index"
    ' + '
    Full board list toggle: toggle-all
    ' + '
    ' + '
    ' + 'You can apply one modifier to a whole group of boards by prefixing it before a brace, instead of suffixing every board:
    ' + 'replace{ g biz pol b gif }g-replace biz-replace pol-replace b-replace gif-replace
    ' + 'Combine modifiers with spaces: title nt{ g a biz }g-title-nt a-title-nt biz-title-nt
    ' + 'Boards before/after the braces and any board that already has its own -modifier are left untouched, so per-board overrides still win: title{ g a-full biz } keeps a-full.' + '
    ' + '
    ' + '
    ' + '[ toggle-all ] [current-title] [g-title / a-title / jp-title] [x / wsg / h] [t-text:"Piracy"]
    ' + 'will give you
    ' + '[ + ] [Technology] [Technology / Anime & Manga / Otaku Culture] [x / wsg / h] [Piracy]
    ' + 'if you are on /g/.' + '
    ' + '
    ' + 'For custom styling, you can wrap groups or individual links in {{ and }}, to wrap them in a span. You can also add classes in double quotes right after the {{. For example:
    ' + '[g-title] {{"favorites"[a-title / jp-title]}}
    ' + 'Results in:
    ' + '[<a [...] >Technology</a>] <span class="favorites">[<a [...] >Anime &amp; Manga</a> / <a [...] >Otaku Culture</a>]</span>' + '
    ' + '
    ' }); const textarea = $('textarea', navContent); $.on(textarea, 'change', $.cb.value); $.on(textarea, 'change', Settings.boardnav); items['boardnav'] = Conf['boardnav']; inputs['boardnav'] = textarea; fsNav.dataset.nextSection = 'changed'; $.add(fsNav, navContent); Settings.makeDetachable(navContent, $('#boardnav-detach', fsNav), { storageKey: 'settings.detachPanel.boardnav', title: 'Custom Board Navigation' }); Settings.renderMainGroups(section, { categories: [ { name: 'Miscellaneous', subgroups: [ ['UI', ['Announcement Hiding', 'Follow Cursor', 'Catalog Links']], ['Settings UI', ['Settings Descriptions as Tooltips']], ['Notifications', ['Desktop Notifications', 'Posting Success Notifications']], ['Keyboard and Navigation', ['Keybinds', 'Comment Expansion', 'Thread Expansion', 'Index Navigation', 'Reply Navigation', 'Unique ID and Capcode Navigation', 'Normalize URL', 'Disable Autoplaying Sounds']] ] }, 'Menu' ] }); const settingsUI = $$('details', section) .find(details => $('summary', details)?.textContent === 'Settings UI'); const selectInputs = settingsUI ? Settings.addSelectRows(settingsUI, [ { name: 'Settings Menu Layout', label: 'Navigation menu', description: 'Position of menu links.', options: [ ['vertical', 'Vertical'], ['horizontal', 'Horizontal'] ] } ]) : dict(); const navLayoutSelect = selectInputs['Settings Menu Layout']; if (navLayoutSelect) { $.on(navLayoutSelect, 'change', function () { const win = Settings.dialog && $('#fourchanx-settings', Settings.dialog); if (win) Settings.setNavLayout(win, this.value); }); } const iconInputs = Settings.addSelectFieldset(section, 'Icons', [ { name: 'Icon Set', label: 'Icon style', description: 'Which icon set the script uses for its buttons and UI. Reload the page to apply.', options: ICON_SETS.map(({ id, name }) => [id, name]) } ]); const iconSetSelect = iconInputs['Icon Set']; if (iconSetSelect) { $.on(iconSetSelect, 'change', function () { Icon.setIconSet(this.value); if (confirm('Icon set changed. Reload the page to apply it everywhere?')) { window.location.reload(); } }); } const mobileInputs = Settings.addSelectFieldset(section, 'Mobile', [ { name: 'Mobile Layout', label: 'Mobile layout', description: 'Adjust the interface for phones and other small touch devices. Auto turns it on when a mobile device is detected.', options: [ ['auto', 'Auto'], ['on', 'On'], ['off', 'Off'] ] } ]); const mobileLayoutSelect = mobileInputs['Mobile Layout']; if (mobileLayoutSelect) { $.on(mobileLayoutSelect, 'change', function () { if (confirm('Mobile layout changed. Reload the page to apply it?')) { window.location.reload(); } }); } $.add(section, fsNav); $.get(items, function (items) { for (const key in items) { const input = inputs[key]; if (input.type === 'checkbox') { input.checked = items[key]; input.parentNode.parentNode.dataset.checked = items[key]; } else { input.value = items[key]; } } if (inputs['Settings Descriptions as Tooltips']) Settings.syncDescriptionTooltipInputs(); }); }, threadsAndPosts(section) { const items = dict(); const inputs = dict(); const fsFmt = $.el('details', { open: true }, { innerHTML: 'Formatting' }); const lookup = Settings.getMainSettingLookup(); const collectGroup = (keys) => { const g = dict(); for (const key of keys) if (lookup[key]) g[key] = lookup[key]; return g; }; Settings.addCheckboxes(fsFmt, collectGroup([ 'Custom Board Titles', 'Persistent Custom Board Titles', 'Color User IDs', 'Count Posts by ID', 'Remove Spoilers', 'Reveal Spoilers', 'Time Formatting', ]), items, inputs); Settings.addSelectRows(fsFmt, [{ name: 'RelativeTime', label: 'Relative Post Dates', description: 'Display dates like "3 minutes ago" inline, on hover, or both.', options: [ ['No', 'Off'], ['Hover', 'Show on hover'], ['Show', 'Show inline (full date on hover)'], ['Both', 'Show timestamp, then relative'], ['BothRelativeFirst', 'Show relative, then timestamp'], ], }]); Settings.addCheckboxes(fsFmt, collectGroup([ 'File Info Formatting', 'Quote Backlinks', ]), items, inputs); $.add(section, fsFmt); const stylingOnlyKeys = new Set([ 'Scrollbar Markers', 'Scrollbar Mark Own Posts', 'Scrollbar Mark Quotes You', 'Scrollbar Mark Ghost Posts', 'Scrollbar Mark Unread Line', 'Scrollbar Marker Position', 'Scrollbar Marker Hover Preview', 'Highlight Posts Quoting You', 'Highlight Own Posts', 'Highlight Ghost Posts', 'Highlight Own Background', 'Highlight You Background', 'Highlight Ghost Background' ]); Settings.renderMainGroups(section, { categories: ['Filtering', 'Monitoring', 'Quote Links'], includeHiddenCount: true, includeSetting: (key) => !stylingOnlyKeys.has(key) }); Settings.addThreadWatcherFieldset(section); Settings.addSelectFieldset(section, 'Thread Title', [ { name: 'Thread Title', label: 'Title Content', description: 'Choose whether thread tabs use the original thread excerpt or the board title.', options: [ ['excerpt', 'Original thread title'], ['board', 'Board title'] ] }, { name: 'Unread Title Count', label: 'Unread Count', description: 'Controls how unread and quoted-you indicators appear in the tab title.', options: [ ['always', 'Always show count'], ['hide-zero', 'Hide zero count'], ['quoted', 'Show quote marker'], ['quoted-hide-zero', 'Quote marker and hide zero'] ] } ]); const fsUC = $.el('details', { open: true, id: 'xt-updater-settings' }, { innerHTML: 'Updater & Cooldown' }); Settings.addCheckboxes(fsUC, Config.updater.checkbox, items, inputs); if (inputs['Scroll BG']) { $.on(inputs['Scroll BG'], 'change', ThreadUpdater.cb.scrollBG); ThreadUpdater.cb.scrollBG(); } if (inputs['Auto Update']) { $.on(inputs['Auto Update'], 'change', ThreadUpdater.setInterval); } const divInterval = $.el('div', { innerHTML: ': Seconds between updates.' }); divInterval.dataset.name = 'Interval'; const intervalInput = $('input', divInterval); $.on(intervalInput, 'change', ThreadUpdater.cb.interval); items['Interval'] = Conf['Interval']; inputs['Interval'] = intervalInput; $.add(fsUC, divInterval); const divCooldown = $.el('div', { innerHTML: ': Seconds to wait after posting.' }); divCooldown.dataset.name = 'customCooldown'; const cooldownInput = $('input', divCooldown); $.on(cooldownInput, 'change', $.cb.value); items['customCooldown'] = Conf['customCooldown']; inputs['customCooldown'] = cooldownInput; $.add(fsUC, divCooldown); Settings.addUpdaterBoardSound(fsUC); const soundHint = $.el('div', { className: 'description', innerHTML: 'Sound library, volume, and per-board overrides: Advanced → Thread updater sound.', }); $.add(fsUC, soundHint); $.add(section, fsUC); $.get(items, function (items) { for (const key in items) { const input = inputs[key]; if (input.type === 'checkbox') { input.checked = items[key]; input.parentNode.parentNode.dataset.checked = items[key]; } else { input.value = items[key]; } } }); }, addThreadWatcherFieldset(section) { const fs = $.el('details', { open: true }, { innerHTML: 'Thread Watcher' }); const items = dict(); const inputs = dict(); const displayName = (name) => ({ 'Show Mark All Read Icon': 'Mark All Read Icon', 'Show Mark Thread Read Icons': 'Mark Thread Read Icons', 'Show OP Thumbnails': 'Thumbnails', }[name] || name); const watcherOrder = [ 'Current Board', 'Auto Update Thread Watcher', 'Auto Watch', 'Auto Watch Reply', 'Auto Prune', 'Auto Prune Read Only', 'Show Page', 'Show Unread Count', 'Show Site Prefix', 'Show OP Thumbnails', 'Show Mark All Read Icon', 'Show Mark Thread Read Icons', 'Require OP Quote Link', ]; const syncThumbSizeToDialog = (size) => { const watcher = $.id('thread-watcher'); if (watcher) watcher.style.setProperty('--watcher-thumb-size', `${size}px`); }; const syncWatcherHeightToDialog = (height) => { const watcher = $.id('thread-watcher'); if (watcher) watcher.style.setProperty('--watcher-max-height', `${height}px`); }; const syncWatcherWidthToDialog = (width) => { const watcher = $.id('thread-watcher'); if (watcher) watcher.style.setProperty('--watcher-max-width', `${width}px`); }; for (const name of watcherOrder) { if (!Config.threadWatcher[name]) continue; const arr = Config.threadWatcher[name]; const description = arr[1] || ''; const hoverDescription = Config.threadWatcher['Thread Watcher Thumbnail Hover']?.[1] || ''; let div; if (name === 'Show OP Thumbnails') { div = $.el('div', { innerHTML: `: ${description}%: ${hoverDescription}` }); div.dataset.name = `${name} Thread Watcher Thumbnail Size Thread Watcher Thumbnail Hover Thread Watcher Thumbnail Preview Size`; div.dataset.settingTitle = displayName(name); Settings.registerSettingDescription(div, `${description} ${hoverDescription} Thread Watcher Thumbnail Size Thread Watcher Thumbnail Hover Thread Watcher Thumbnail Preview Size`); const sizeInput = $('input[name="Thread Watcher Thumbnail Size"]', div); const previewToggle = $('input[name="Thread Watcher Thumbnail Hover"]', div); const previewSizeInput = $('input[name="Thread Watcher Thumbnail Preview Size"]', div); $.on(sizeInput, 'change', function () { let size = parseInt(this.value, 10); if (isNaN(size)) size = 40; size = Math.max(16, Math.min(160, size)); this.value = `${size}`; $.set(this.name, size); Conf[this.name] = size; syncThumbSizeToDialog(size); }); $.on(previewToggle, 'change', $.cb.checked); $.on(previewToggle, 'change', function () { if (!this.checked) { const hover = $.id('tw-ihover'); if (hover) { hover.hidden = true; hover.removeAttribute('src'); hover.removeAttribute('style'); } } }); $.on(previewSizeInput, 'change', function () { let size = parseInt(this.value, 10); if (isNaN(size)) size = 40; size = Math.max(10, Math.min(99, size)); this.value = `${size}`; $.set(this.name, size); Conf[this.name] = size; }); items['Thread Watcher Thumbnail Size'] = Conf['Thread Watcher Thumbnail Size']; items['Thread Watcher Thumbnail Hover'] = Conf['Thread Watcher Thumbnail Hover']; items['Thread Watcher Thumbnail Preview Size'] = Conf['Thread Watcher Thumbnail Preview Size']; inputs['Thread Watcher Thumbnail Size'] = sizeInput; inputs['Thread Watcher Thumbnail Hover'] = previewToggle; inputs['Thread Watcher Thumbnail Preview Size'] = previewSizeInput; } else { div = $.el('div', { innerHTML: `: ${description}` }); div.dataset.name = name; div.dataset.settingTitle = displayName(name); Settings.registerSettingDescription(div, description); } const level = arr[2] || 0; if (level > 0) div.classList.add('thread-watcher-subsetting'); const input = $('input', div); $.on(input, 'change', $.cb.checked); $.on(input, 'change', function () { this.parentNode.parentNode.dataset.checked = this.checked; }); items[name] = Conf[name]; inputs[name] = input; $.add(fs, div); } ThreadWatcher.linkPruneExclusive([inputs['Auto Prune'], inputs['Auto Prune Read Only']]); const heightDiv = $.el('div', { innerHTML: ': Maximum watched-thread list height and width in pixels.' }); heightDiv.dataset.name = 'Thread Watcher Max Height Thread Watcher Max Width'; heightDiv.dataset.settingTitle = 'TW Max H/W'; Settings.registerSettingDescription(heightDiv, 'Maximum watched-thread list height and width in pixels.'); const heightInput = $('input[name="Thread Watcher Max Height"]', heightDiv); const widthInput = $('input[name="Thread Watcher Max Width"]', heightDiv); $.on(heightInput, 'change', function () { let height = parseInt(this.value, 10); if (isNaN(height)) height = 210; height = Math.max(120, Math.min(999, height)); this.value = `${height}`; $.set(this.name, height); Conf[this.name] = height; syncWatcherHeightToDialog(height); }); $.on(widthInput, 'change', function () { let width = parseInt(this.value, 10); if (isNaN(width)) width = 250; width = Math.max(120, Math.min(999, width)); this.value = `${width}`; $.set(this.name, width); Conf[this.name] = width; syncWatcherWidthToDialog(width); }); items['Thread Watcher Max Height'] = Conf['Thread Watcher Max Height']; items['Thread Watcher Max Width'] = Conf['Thread Watcher Max Width']; inputs['Thread Watcher Max Height'] = heightInput; inputs['Thread Watcher Max Width'] = widthInput; $.add(fs, heightDiv); $.add(section, fs); $.get(items, function (items) { for (const key in items) { const input = inputs[key]; if (input.type === 'checkbox') { input.checked = items[key]; input.parentNode.parentNode.dataset.checked = items[key]; } else if (input.tagName === 'SELECT') { input.value = items[key] || 'bottom'; } else { input.value = items[key]; } } const thumbSize = parseInt(`${items['Thread Watcher Thumbnail Size']}`, 10); if (Number.isFinite(thumbSize)) syncThumbSizeToDialog(Math.max(16, Math.min(160, thumbSize))); const watcherHeight = parseInt(`${items['Thread Watcher Max Height']}`, 10); if (Number.isFinite(watcherHeight)) syncWatcherHeightToDialog(Math.max(120, Math.min(999, watcherHeight))); const watcherWidth = parseInt(`${items['Thread Watcher Max Width']}`, 10); if (Number.isFinite(watcherWidth)) syncWatcherWidthToDialog(Math.max(120, Math.min(999, watcherWidth))); }); }, mobile(section) { const inputs = Settings.addSelectFieldset(section, 'Layout', [ { name: 'Mobile Bar Layout', label: 'Icon bar', description: 'Where the shortcut icon bar sits in the mobile layout. The board navigation bar takes the opposite edge.', options: [ ['icons-bottom', 'Icons bottom'], ['icons-top', 'Icons top'] ] } ]); const barSelect = inputs['Mobile Bar Layout']; if (barSelect) { $.on(barSelect, 'change', function () { Header.setBarLayout(this.value); }); } const fsReplies = $.el('details', { open: true }, { innerHTML: 'Replies' }); const repliesDesc = 'Show replies to a post as a badge that opens them in a popup. When disabled, classic backlinks are shown instead.'; const repliesDiv = $.el('div', { innerHTML: `: ${repliesDesc}` }); repliesDiv.dataset.name = 'Mobile Replies Popup'; repliesDiv.dataset.settingTitle = 'Replies popup'; Settings.registerSettingDescription(repliesDiv, repliesDesc); const repliesInput = $('input', repliesDiv); $.on(repliesInput, 'change', $.cb.checked); $.on(repliesInput, 'change', function () { this.parentNode.parentNode.dataset.checked = this.checked; doc.classList.toggle('xt-replies-chip', this.checked); }); $.add(fsReplies, repliesDiv); $.add(section, fsReplies); $.get({ 'Mobile Replies Popup': Conf['Mobile Replies Popup'] }, (items) => { repliesInput.checked = !!items['Mobile Replies Popup']; repliesDiv.dataset.checked = `${repliesInput.checked}`; }); const fsNav = $.el('details', { open: true }, { innerHTML: 'Board navigation' }); const favDesc = 'Boards listed in the Favorites tab of the board picker, as space-separated board codes, e.g. "g v tv". Starring boards in the picker edits the same list. Separate from the desktop custom board navigation in Interface.'; const favDiv = $.el('div'); favDiv.dataset.name = 'favoriteBoards'; favDiv.dataset.settingTitle = 'Favorite boards'; Settings.registerSettingDescription(favDiv, favDesc); const favInput = $.el('input', { name: 'favoriteBoards', className: 'field', spellcheck: false }); const favLabel = $.el('label'); $.add(favLabel, [ $.el('span', { className: 'setting-title', textContent: 'Favorite boards: ' }), favInput ]); $.add(favDiv, [favLabel, Settings.descriptionSpan(favDesc)]); $.add(fsNav, favDiv); $.add(section, fsNav); $.on(favInput, 'change', () => { const value = serializeFavoriteBoards(parseFavoriteBoards(favInput.value)); favInput.value = value; Conf['favoriteBoards'] = value; $.set('favoriteBoards', value); if (!Conf['favoriteBoardsSeeded']) { Conf['favoriteBoardsSeeded'] = true; $.set('favoriteBoardsSeeded', true); } }); $.get({ favoriteBoards: Conf['favoriteBoards'] }, (items) => { favInput.value = items['favoriteBoards']; }); }, media(section) { const items = dict(); const inputs = dict(); const lookup = Settings.getMainSettingLookup(); const groups = [ ['Image Behavior', ['Image Expansion', 'Image Hover', 'Image Hover in Catalog', 'Replace Thumbnails', 'Replace GIF', 'Replace JPG', 'Replace PNG', 'Replace WEBM', 'Restart when Opened']], ['Images', ['Gallery', 'Fullscreen Gallery', 'PDF in Gallery', 'Sauce', 'Reveal Spoiler Thumbnails', 'Image Prefetching', 'Fappe Tyme', 'Werk Tyme']], ['Videos', ['WEBM Metadata', 'Video Duration Badge', 'Autoplay', 'Show Controls', 'Click Passthrough', 'Allow Sound', 'Mouse Wheel Volume', 'Loop in New Tab', 'Volume in New Tab', 'Enable sound posts']] ]; for (const [legendTitle, keys] of groups) { const fs = $.el('details', { open: true }, { innerHTML: `${legendTitle}` }); const group = dict(); for (const key of keys) { if (lookup[key]) group[key] = lookup[key]; } if (!Settings.addCheckboxes(fs, group, items, inputs)) continue; $.add(section, fs); } if (g.SITE && noWebmTitleMetadataSites.includes(g.SITE.ID)) { const wmInput = inputs['WEBM Metadata']; if (wmInput) { wmInput.disabled = true; const row = wmInput.parentNode.parentNode; $.addClass(row, 'setting-unavailable'); const desc = $('.setting-description', row); if (desc) { $.add(desc, $.tn(' (not available on 4chan, which strips webm titles)')); } } } Settings.renderMainGroups(section, { categories: ['Linkification'] }); $.get(items, function (items) { for (const key in items) { const input = inputs[key]; if (!input) continue; input.checked = items[key]; input.parentNode.parentNode.dataset.checked = items[key]; } }); const sauceFS = $.el('details', { open: true }, { innerHTML: 'Sauce' }); const sauceWrap = $.el('div'); Settings.sauce(sauceWrap); $.add(sauceFS, sauceWrap); $.add(section, sauceFS); Settings.makeDetachable($('.sauce-section', sauceWrap), $('#sauce-detach', sauceFS), { storageKey: 'settings.detachPanel.sauce', title: 'Sauce' }); }, posting(section) { Settings.renderMainGroups(section, { categories: ['Posting and Captchas'], includeSetting: (key) => ![ 'Comment Preview', 'Comment Preview Default Mode', 'Comment Preview Attach Location', 'Comment Preview Remember Float Position', 'Comment Preview Thread Behavior', 'Comment Preview Catalog Behavior', 'Show Comment Preview Header Icon', 'Dogiri Timeline Size', 'Stacked TCaptcha Style', ].includes(key), }); const rememberQRState = $('input[name="QR Drafts"]', section); if (rememberQRState) { $.on(rememberQRState, 'change', () => $.event('QRStateChanged', null)); } const stackedToggle = $('input[name="Stacked TCaptcha"]', section); const stackedRow = stackedToggle?.closest('div[data-name="Stacked TCaptcha"]'); if (stackedRow) { const styleDescription = String(Config.main['Posting and Captchas']['Stacked TCaptcha Style'][1]); const styleRow = $.el('div'); styleRow.dataset.name = 'Stacked TCaptcha Style'; styleRow.dataset.settingTitle = 'Stacked Captcha Style'; Settings.registerSettingDescription(styleRow, styleDescription); const styleLabel = $.el('label'); const styleSelect = $.el('select', { name: 'Stacked TCaptcha Style' }); for (const [value, text] of [ ['classic', 'Classic row'], ['inline', 'Inline chips'], ['dots', 'Stepper dots'], ]) { $.add(styleSelect, $.el('option', { value, textContent: text })); } $.on(styleSelect, 'change', $.cb.value); $.on(styleSelect, 'change', () => $.event('StackedCaptchaStyleChanged', null)); $.add(styleLabel, [$.el('span', { className: 'setting-title', textContent: 'Stacked Captcha Style: ' }), styleSelect]); $.add(styleRow, [styleLabel, Settings.descriptionSpan(styleDescription)]); const stackedSub = $.el('div', { className: 'suboption-list' }); $.add(stackedSub, styleRow); $.add(stackedRow, stackedSub); $.get({ 'Stacked TCaptcha Style': Conf['Stacked TCaptcha Style'] }, (items) => { styleSelect.value = normalizeCaptchaStyle(items['Stacked TCaptcha Style']); }); } const fs = $.el('details', { open: true }, { innerHTML: 'Comment Preview' }); const row = $.el('div', { innerHTML: `: ${Config.main['Posting and Captchas']['Comment Preview'][1]}`, }); row.dataset.name = 'Comment Preview'; row.dataset.settingTitle = 'Comment Preview'; Settings.registerSettingDescription(row, String(Config.main['Posting and Captchas']['Comment Preview'][1])); const toggle = $('input[name="Comment Preview"]', row); $.on(toggle, 'change', $.cb.checked); $.on(toggle, 'change', function () { this.parentNode.parentNode.dataset.checked = this.checked; }); $.on(toggle, 'change', () => $.event('QRCommentPreviewChanged')); const sub = $.el('div', { className: 'suboption-list' }); const defaultModeDescription = String(Config.main['Posting and Captchas']['Comment Preview Default Mode'][1]); const defaultModeRow = $.el('div'); defaultModeRow.dataset.name = 'Comment Preview Default Mode'; defaultModeRow.dataset.settingTitle = 'Default Preview Mode'; Settings.registerSettingDescription(defaultModeRow, defaultModeDescription); const defaultModeLabel = $.el('label'); const defaultModeSelect = $.el('select', { name: 'Comment Preview Default Mode' }); for (const [value, text] of [ ['attached', 'Attached to QR'], ['inline', 'Docked inline'], ['remember', 'Remember last mode'], ]) { $.add(defaultModeSelect, $.el('option', { value, textContent: text })); } $.on(defaultModeSelect, 'change', $.cb.value); $.on(defaultModeSelect, 'change', () => $.event('QRCommentPreviewChanged')); $.add(defaultModeLabel, [$.el('span', { className: 'setting-title', textContent: 'Default Preview Mode: ' }), defaultModeSelect]); $.add(defaultModeRow, [ defaultModeLabel, Settings.descriptionSpan(defaultModeDescription), ]); const attachLocationDescription = String(Config.main['Posting and Captchas']['Comment Preview Attach Location'][1]); const attachLocationRow = $.el('div'); attachLocationRow.dataset.name = 'Comment Preview Attach Location'; attachLocationRow.dataset.settingTitle = 'Attach to QR Location'; Settings.registerSettingDescription(attachLocationRow, attachLocationDescription); const attachLocationLabel = $.el('label'); const attachLocationSelect = $.el('select', { name: 'Comment Preview Attach Location' }); for (const [value, text] of [ ['auto', 'Auto (prefer bottom)'], ['bottom', 'Bottom'], ['top', 'Top'], ['right', 'Right'], ['left', 'Left'], ]) { $.add(attachLocationSelect, $.el('option', { value, textContent: text })); } $.on(attachLocationSelect, 'change', $.cb.value); $.on(attachLocationSelect, 'change', () => $.event('QRCommentPreviewChanged')); $.add(attachLocationLabel, [$.el('span', { className: 'setting-title', textContent: 'Attach to QR Location: ' }), attachLocationSelect]); $.add(attachLocationRow, [ attachLocationLabel, Settings.descriptionSpan(attachLocationDescription), ]); const positionRow = $.el('div'); positionRow.dataset.name = 'Comment Preview Inline Behavior'; positionRow.dataset.settingTitle = 'Inline Behavior'; Settings.registerSettingDescription(positionRow, 'When you dock the floating preview into the thread (the arrow icon in the preview header), how it is inserted: "Scroll to bottom" stitches the preview at the very end of the thread and scrolls there so you see the literal end result; "Insert in place" drops it after the post nearest the bottom of your screen and keeps it there as you scroll (no page jump).'); const label = $.el('label'); const select = $.el('select', { name: 'Comment Preview Inline Behavior' }); for (const [value, text] of [ ['scroll', 'Scroll to bottom and dock at thread end'], ['inplace', 'Insert in place near viewport (follow scroll)'], ]) { $.add(select, $.el('option', { value, textContent: text })); } $.on(select, 'change', $.cb.value); $.on(select, 'change', () => $.event('QRCommentPreviewChanged')); $.add(label, [$.el('span', { className: 'setting-title', textContent: 'Inline Behavior: ' }), select]); $.add(positionRow, [ label, Settings.descriptionSpan('How the preview is inserted when you dock it into the thread.'), ]); const rememberFloatDescription = String(Config.main['Posting and Captchas']['Comment Preview Remember Float Position'][1]); const rememberFloatRow = $.el('div', { innerHTML: `: ${rememberFloatDescription}`, }); rememberFloatRow.dataset.name = 'Comment Preview Remember Float Position'; rememberFloatRow.dataset.settingTitle = 'Remember Floating Position'; Settings.registerSettingDescription(rememberFloatRow, rememberFloatDescription); const rememberFloatToggle = $('input[name="Comment Preview Remember Float Position"]', rememberFloatRow); $.on(rememberFloatToggle, 'change', $.cb.checked); $.on(rememberFloatToggle, 'change', function () { this.parentNode.parentNode.dataset.checked = this.checked; }); $.on(rememberFloatToggle, 'change', () => $.event('QRCommentPreviewChanged')); const threadBehaviorDescription = String(Config.main['Posting and Captchas']['Comment Preview Thread Behavior'][1]); const threadBehaviorRow = $.el('div'); threadBehaviorRow.dataset.name = 'Comment Preview Thread Behavior'; threadBehaviorRow.dataset.settingTitle = 'Thread Behavior'; Settings.registerSettingDescription(threadBehaviorRow, threadBehaviorDescription); const threadBehaviorLabel = $.el('label'); const threadBehaviorSelect = $.el('select', { name: 'Comment Preview Thread Behavior' }); for (const [value, text] of [ ['normal', 'Normal (show as usual)'], ['until-content', 'Hide until comment/file added'], ['manual', 'Hide until revealed via titlebar icon'], ]) { $.add(threadBehaviorSelect, $.el('option', { value, textContent: text })); } $.on(threadBehaviorSelect, 'change', $.cb.value); $.on(threadBehaviorSelect, 'change', () => $.event('QRCommentPreviewChanged', null)); $.add(threadBehaviorLabel, [$.el('span', { className: 'setting-title', textContent: 'Thread Behavior: ' }), threadBehaviorSelect]); $.add(threadBehaviorRow, [ threadBehaviorLabel, Settings.descriptionSpan(threadBehaviorDescription), ]); const catalogBehaviorDescription = String(Config.main['Posting and Captchas']['Comment Preview Catalog Behavior'][1]); const catalogBehaviorRow = $.el('div'); catalogBehaviorRow.dataset.name = 'Comment Preview Catalog Behavior'; catalogBehaviorRow.dataset.settingTitle = 'Catalog/Index Behavior'; Settings.registerSettingDescription(catalogBehaviorRow, catalogBehaviorDescription); const catalogBehaviorLabel = $.el('label'); const catalogBehaviorSelect = $.el('select', { name: 'Comment Preview Catalog Behavior' }); for (const [value, text] of [ ['normal', 'Normal (float as usual)'], ['until-content', 'Hide until comment/file added'], ['manual', 'Hide until revealed via titlebar icon'], ]) { $.add(catalogBehaviorSelect, $.el('option', { value, textContent: text })); } $.on(catalogBehaviorSelect, 'change', $.cb.value); $.on(catalogBehaviorSelect, 'change', () => $.event('QRCommentPreviewChanged', null)); $.add(catalogBehaviorLabel, [$.el('span', { className: 'setting-title', textContent: 'Catalog/Index Behavior: ' }), catalogBehaviorSelect]); $.add(catalogBehaviorRow, [ catalogBehaviorLabel, Settings.descriptionSpan(catalogBehaviorDescription), ]); const iconDescription = String(Config.main['Posting and Captchas']['Show Comment Preview Header Icon'][1]); const iconRow = $.el('div', { innerHTML: `: ${iconDescription}`, }); iconRow.dataset.name = 'Show Comment Preview Header Icon'; iconRow.dataset.settingTitle = 'Show QR Titlebar Toggle'; Settings.registerSettingDescription(iconRow, iconDescription); const iconToggle = $('input[name="Show Comment Preview Header Icon"]', iconRow); $.on(iconToggle, 'change', $.cb.checked); $.on(iconToggle, 'change', function () { this.parentNode.parentNode.dataset.checked = this.checked; }); $.on(iconToggle, 'change', () => $.event('QRCommentPreviewChanged', null)); const updateCommentPreviewModeRows = (mode) => { const m = ['inline', 'remember'].includes(mode) ? mode : 'attached'; const showInline = m === 'inline' || m === 'remember'; const showAttachLoc = m === 'attached' || m === 'remember'; const showRememberFloat = m === 'attached'; positionRow.hidden = !showInline; attachLocationRow.hidden = !showAttachLoc; rememberFloatRow.hidden = !showRememberFloat; }; $.on(defaultModeSelect, 'change', () => updateCommentPreviewModeRows(defaultModeSelect.value)); updateCommentPreviewModeRows(defaultModeSelect.value); $.add(sub, defaultModeRow); $.add(sub, attachLocationRow); $.add(sub, positionRow); $.add(sub, rememberFloatRow); $.add(sub, threadBehaviorRow); $.add(sub, catalogBehaviorRow); $.add(row, sub); $.add(fs, row); $.add(fs, iconRow); $.add(section, fs); const updateCommentPreviewSettings = (items) => { toggle.checked = !!items['Comment Preview']; row.dataset.checked = toggle.checked ? 'true' : 'false'; defaultModeSelect.value = ['inline', 'remember'].includes(items['Comment Preview Default Mode']) ? items['Comment Preview Default Mode'] : 'attached'; updateCommentPreviewModeRows(defaultModeSelect.value); attachLocationSelect.value = ['bottom', 'top', 'right', 'left'].includes(items['Comment Preview Attach Location']) ? items['Comment Preview Attach Location'] : 'auto'; select.value = items['Comment Preview Inline Behavior'] === 'inplace' ? 'inplace' : 'scroll'; threadBehaviorSelect.value = ['until-content', 'manual'].includes(items['Comment Preview Thread Behavior']) ? items['Comment Preview Thread Behavior'] : 'normal'; catalogBehaviorSelect.value = ['until-content', 'manual'].includes(items['Comment Preview Catalog Behavior']) ? items['Comment Preview Catalog Behavior'] : 'normal'; rememberFloatToggle.checked = !!items['Comment Preview Remember Float Position']; rememberFloatRow.dataset.checked = rememberFloatToggle.checked ? 'true' : 'false'; iconToggle.checked = items['Show Comment Preview Header Icon'] !== false; iconRow.dataset.checked = iconToggle.checked ? 'true' : 'false'; }; $.get({ 'Comment Preview': Conf['Comment Preview'], 'Comment Preview Default Mode': Conf['Comment Preview Default Mode'], 'Comment Preview Attach Location': Conf['Comment Preview Attach Location'], 'Comment Preview Inline Behavior': Conf['Comment Preview Inline Behavior'], 'Comment Preview Remember Float Position': Conf['Comment Preview Remember Float Position'], 'Comment Preview Thread Behavior': Conf['Comment Preview Thread Behavior'], 'Comment Preview Catalog Behavior': Conf['Comment Preview Catalog Behavior'], 'Show Comment Preview Header Icon': Conf['Show Comment Preview Header Icon'], }, updateCommentPreviewSettings); }, styling(section) { let input, name; $.extend(section, { innerHTML: StylingPage }); const externalStyler = Settings.detectExternalStyler(); if (externalStyler) { const refreshToggles = Settings.setupStylingSectionToggles(section); const box = $.el('div', { className: 'styling-extstyle-box' }); const text = $.el('div', { className: 'styling-extstyle-text', innerHTML: `${externalStyler.name} is detected. ` + 'Use the checkbox in each section title below to choose what 4chan-neXT styles and what your styling script owns. ' + 'Turning a section off removes its effect from the page without changing the settings inside it.' }); const buttons = $.el('div', { className: 'styling-extstyle-buttons' }); if (externalStyler.settingsLink) { const openButton = $.el('button', { type: 'button', className: 'styling-extstyle-open', textContent: `Open ${externalStyler.name} Settings`, }); $.on(openButton, 'click', e => { e.preventDefault(); Settings.openExternalStylerSettings(); }); $.add(buttons, openButton); } const recommendButton = $.el('button', { type: 'button', className: 'styling-extstyle-recommend', title: 'Turn off the sections a styling script owns (Site Style, Text Colors, Custom CSS) and keep the others on. Does not change the settings inside any section.', textContent: 'Apply recommended settings', }); $.on(recommendButton, 'click', e => { e.preventDefault(); Settings.applyRecommendedStylingSections(); refreshToggles(); Settings.applyStylingSectionRuntime(); }); $.add(buttons, recommendButton); $.add(box, [text, buttons]); section.insertBefore(box, section.firstChild); } const inputs = dict(); for (input of $$('[name]', section)) { inputs[input.name] = input; } for (const key of styleVariantKeys) { const inp = inputs[key]; if (!inp) continue; const detail = inp.closest('details'); if (detail) detail.dataset.variantAware = 'true'; } Settings.stylingEditingVariant = Settings.getBoardVariant(); const renameVariantInputs = (variant) => { for (const key of styleVariantKeys) { const inp = inputs[key]; if (inp) inp.name = Settings.variantKey(key, variant); } }; renameVariantInputs(Settings.stylingEditingVariant); Settings.populateSiteStylePicker(section, inputs['siteStyle']); Settings.bindSiteStylePicker(section); const setCheckedState = (checkbox) => { const container = checkbox.closest('[data-name]'); if (!container) return; const owner = container.firstElementChild?.querySelector?.('input[type="checkbox"]'); if (owner !== checkbox) return; container.dataset.checked = checkbox.checked ? 'true' : 'false'; }; const catalogHighlightKeys = [ 'Catalog Highlight Own Posts', 'Catalog Highlight Watched Threads', ]; const markerControlRows = [ { type: 'own', onKey: 'Scrollbar Mark Own Posts', colorKey: 'Scroll Marker Own Color', opacityKey: 'Scroll Marker Own Opacity', matchKey: 'Scroll Marker Own Match Highlight', highlightKey: 'Highlight Own Color' }, { type: 'you', onKey: 'Scrollbar Mark Quotes You', colorKey: 'Scroll Marker You Color', opacityKey: 'Scroll Marker You Opacity', matchKey: 'Scroll Marker You Match Highlight', highlightKey: 'Highlight You Color' }, { type: 'ghost', onKey: 'Scrollbar Mark Ghost Posts', colorKey: 'Scroll Marker Ghost Color', opacityKey: 'Scroll Marker Ghost Opacity', matchKey: 'Scroll Marker Ghost Match Highlight', highlightKey: 'Highlight Ghost Color' }, { type: 'unread', onKey: 'Scrollbar Mark Unread Line', colorKey: 'Scroll Marker Unread Color', opacityKey: 'Scroll Marker Unread Opacity', matchKey: null, highlightKey: null }, ]; const markerToggleKeys = new Set([ 'Scrollbar Markers', ...markerControlRows.map(r => r.onKey), ...markerControlRows.map(r => r.matchKey).filter(Boolean), ]); const threadHighlightControlRows = [ { onKey: 'Highlight Own Posts', controls: ['Highlight Own Color', 'Highlight Own Opacity', 'Highlight Own Background', 'Highlight Own Edge Width', 'Highlight Own Border Style', 'Highlight Own Text Mode', 'Highlight Own Text Color', 'Highlight Own Link Color', 'Highlight Own Quote Color', 'Highlight Own Dead Link Color'] }, { onKey: 'Highlight Posts Quoting You', controls: ['Highlight You Color', 'Highlight You Opacity', 'Highlight You Background', 'Highlight You Edge Width', 'Highlight You Border Style', 'Highlight You Text Mode', 'Highlight You Text Color', 'Highlight You Link Color', 'Highlight You Quote Color', 'Highlight You Dead Link Color'] }, { onKey: 'Highlight Ghost Posts', controls: ['Highlight Ghost Color', 'Highlight Ghost Opacity', 'Highlight Ghost Background', 'Highlight Ghost Edge Width', 'Highlight Ghost Border Style', 'Highlight Ghost Text Mode', 'Highlight Ghost Text Color', 'Highlight Ghost Link Color', 'Highlight Ghost Quote Color', 'Highlight Ghost Dead Link Color'] }, ]; const themeDefaultSettings = [ ['Enable Thread Highlights', true], ['Highlight Own Posts', true], ['Highlight Posts Quoting You', true], ['Highlight Ghost Posts', true], ['Highlight Own Color', Settings.THEME_BORDER_HIGHLIGHT], ['Highlight You Color', Settings.THEME_BORDER_HIGHLIGHT], ['Highlight Ghost Color', ''], ['Highlight Own Opacity', ''], ['Highlight You Opacity', ''], ['Highlight Ghost Opacity', ''], ['Thread Highlight Edge Width', 3], ['Highlight Own Edge Width', 3], ['Highlight You Edge Width', 3], ['Highlight Ghost Edge Width', 3], ['Highlight Own Background', false], ['Highlight You Background', false], ['Highlight Ghost Background', false], ['Highlight Own Text Mode', 'default'], ['Highlight You Text Mode', 'default'], ['Highlight Ghost Text Mode', 'default'], ['Highlight Own Text Color', ''], ['Highlight Own Link Color', ''], ['Highlight Own Quote Color', ''], ['Highlight Own Dead Link Color', ''], ['Highlight You Text Color', ''], ['Highlight You Link Color', ''], ['Highlight You Quote Color', ''], ['Highlight You Dead Link Color', ''], ['Highlight Ghost Text Color', ''], ['Highlight Ghost Link Color', ''], ['Highlight Ghost Quote Color', ''], ['Highlight Ghost Dead Link Color', ''], ['Enable Catalog Highlights', false], ['Catalog Highlight Own Posts', true], ['Catalog Highlight Watched Threads', true], ['Catalog Highlight Own Color', ''], ['Catalog Highlight Own Opacity', ''], ['Catalog Highlight Own Background', false], ['Catalog Highlight Watched Color', ''], ['Catalog Highlight Watched Opacity', ''], ['Catalog Highlight Watched Background', false], ['Catalog Highlight Border Width', 3], ['Catalog Highlight Own Border Width', 3], ['Catalog Highlight Watched Border Width', 3], ['Catalog Highlight Own Text Mode', 'default'], ['Catalog Highlight Own Text Color', ''], ['Catalog Highlight Own Subject Color', ''], ['Catalog Highlight Own Link Color', ''], ['Catalog Highlight Own Quote Color', ''], ['Catalog Highlight Own Dead Link Color', ''], ['Catalog Highlight Watched Text Mode', 'default'], ['Catalog Highlight Watched Text Color', ''], ['Catalog Highlight Watched Subject Color', ''], ['Catalog Highlight Watched Link Color', ''], ['Catalog Highlight Watched Quote Color', ''], ['Catalog Highlight Watched Dead Link Color', ''], ]; const threadHighlightToggleKeys = new Set([ 'Enable Thread Highlights', ...threadHighlightControlRows.map(r => r.onKey), ]); const highlightTextControlGroups = [ { manualGroup: 'own', modeKey: 'Highlight Own Text Mode', colorKey: 'Highlight Own Color', opacityKey: 'Highlight Own Opacity', bgKey: 'Highlight Own Background', keys: ['Highlight Own Text Color', 'Highlight Own Link Color', 'Highlight Own Quote Color', 'Highlight Own Dead Link Color'], }, { manualGroup: 'you', modeKey: 'Highlight You Text Mode', colorKey: 'Highlight You Color', opacityKey: 'Highlight You Opacity', bgKey: 'Highlight You Background', keys: ['Highlight You Text Color', 'Highlight You Link Color', 'Highlight You Quote Color', 'Highlight You Dead Link Color'], }, { manualGroup: 'ghost', modeKey: 'Highlight Ghost Text Mode', colorKey: 'Highlight Ghost Color', opacityKey: 'Highlight Ghost Opacity', bgKey: 'Highlight Ghost Background', keys: ['Highlight Ghost Text Color', 'Highlight Ghost Link Color', 'Highlight Ghost Quote Color', 'Highlight Ghost Dead Link Color'], }, { manualGroup: 'catalog-own', modeKey: 'Catalog Highlight Own Text Mode', colorKey: 'Catalog Highlight Own Color', opacityKey: 'Catalog Highlight Own Opacity', bgKey: 'Catalog Highlight Own Background', keys: ['Catalog Highlight Own Text Color', 'Catalog Highlight Own Subject Color', 'Catalog Highlight Own Link Color', 'Catalog Highlight Own Quote Color', 'Catalog Highlight Own Dead Link Color'], }, { manualGroup: 'catalog-watched', modeKey: 'Catalog Highlight Watched Text Mode', colorKey: 'Catalog Highlight Watched Color', opacityKey: 'Catalog Highlight Watched Opacity', bgKey: 'Catalog Highlight Watched Background', keys: ['Catalog Highlight Watched Text Color', 'Catalog Highlight Watched Subject Color', 'Catalog Highlight Watched Link Color', 'Catalog Highlight Watched Quote Color', 'Catalog Highlight Watched Dead Link Color'], }, { manualGroup: 'catalog-filter', modeKey: 'Catalog Highlight Filter Text Mode', colorKey: 'Catalog Highlight Filter Color', opacityKey: 'Catalog Highlight Filter Opacity', bgKey: 'Catalog Highlight Filter Background', keys: ['Catalog Highlight Filter Text Color', 'Catalog Highlight Filter Subject Color', 'Catalog Highlight Filter Link Color', 'Catalog Highlight Filter Quote Color', 'Catalog Highlight Filter Dead Link Color'], }, ]; const textColorKeys = [ 'Text Color', 'Link Text Color', 'Quote Text Color', 'Dead Link Text Color', ]; const hexEditableColorKeys = new Set([ 'Highlight Own Color', 'Highlight You Color', 'Highlight Ghost Color', 'Catalog Highlight Own Color', 'Catalog Highlight Watched Color', 'Catalog Highlight Filter Color', 'Scroll Marker Own Color', 'Scroll Marker You Color', 'Scroll Marker Ghost Color', 'Scroll Marker Unread Color', ]); const markerRefreshKeys = new Set([ 'Scrollbar Markers', 'Scrollbar Mark Own Posts', 'Scrollbar Mark Quotes You', 'Scrollbar Mark Ghost Posts', 'Scrollbar Mark Unread Line', 'Scroll Marker Own Match Highlight', 'Scroll Marker You Match Highlight', 'Scroll Marker Ghost Match Highlight', 'Enable Thread Highlights', 'Enable Catalog Highlights', 'Catalog Highlight Own Posts', 'Catalog Highlight Watched Threads', 'Highlight Own Posts', 'Highlight Posts Quoting You', 'Unread Line', 'siteStyle', 'siteStyleHome', ]); const textColorModeSelect = inputs['textColorMode']; const textColorManualTree = $('#styling-text-color-manual', section); const highlightTextKeys = new Set(highlightTextControlGroups.flatMap(group => Array.from(group.keys))); const editVariant = () => Settings.stylingEditingVariant || Settings.getBoardVariant(); const editConf = (baseKey) => Settings.styleConf(baseKey, editVariant()); const writeEditConf = (baseKey, value) => { const storageKey = Settings.styleVariantKeySet.has(baseKey) ? Settings.variantKey(baseKey, editVariant()) : baseKey; Conf[storageKey] = value; $.set(storageKey, value); }; const groupTextMode = (group) => { const el = inputs[group.modeKey]; return Settings.resolveTextMode(el ? el.value : editConf(group.modeKey)); }; const baseTextPalette = (baseBackground) => { const textColorMode = editConf('textColorMode') === 'manual' ? 'manual' : 'auto'; const autoTextPalette = Settings.autoTextPalette(baseBackground); return { text: textColorMode === 'auto' ? autoTextPalette.text : (editConf('Text Color') || autoTextPalette.text), link: textColorMode === 'auto' ? autoTextPalette.link : (editConf('Link Text Color') || autoTextPalette.link), quote: textColorMode === 'auto' ? autoTextPalette.quote : (editConf('Quote Text Color') || autoTextPalette.quote), deadLink: textColorMode === 'auto' ? autoTextPalette.deadLink : (editConf('Dead Link Text Color') || autoTextPalette.deadLink), }; }; const syncAutoHighlightPreviewInputs = () => { const baseBackground = Settings.getTextBaseBackground(); const postBackground = Settings.getPostBaseBackground(); const basePalette = baseTextPalette(baseBackground); const v = editVariant(); for (const group of highlightTextControlGroups) { if (groupTextMode(group) !== 'auto') continue; const groupBackground = group.manualGroup.startsWith('catalog-') ? baseBackground : postBackground; const groupBasePalette = group.manualGroup.startsWith('catalog-') ? basePalette : baseTextPalette(postBackground); const palette = !editConf(group.bgKey) ? groupBasePalette : (Settings.autoHighlightTextPalette(group.colorKey, group.opacityKey, groupBackground, v) || groupBasePalette); const nextValues = [palette.text, palette.link, palette.quote, palette.deadLink]; for (let i = 0; i < group.keys.length; i++) { const key = group.keys[i]; const next = nextValues[i]; if (!next) continue; const colorInput = inputs[key]; if (!colorInput) continue; colorInput.value = next; delete colorInput.dataset.unset; } } }; const seedManualHighlightTextColors = (targetGroup, overwrite = false) => { const baseBackground = Settings.getTextBaseBackground(); const postBackground = Settings.getPostBaseBackground(); const basePalette = baseTextPalette(baseBackground); const groups = targetGroup ? [targetGroup] : highlightTextControlGroups; const v = editVariant(); for (const group of groups) { if (groupTextMode(group) !== 'manual') continue; const groupBackground = group.manualGroup.startsWith('catalog-') ? baseBackground : postBackground; const groupBasePalette = group.manualGroup.startsWith('catalog-') ? basePalette : baseTextPalette(postBackground); const autoPalette = !editConf(group.bgKey) ? groupBasePalette : (Settings.autoHighlightTextPalette(group.colorKey, group.opacityKey, groupBackground, v) || groupBasePalette); const nextValues = [autoPalette.text, autoPalette.link, autoPalette.quote, autoPalette.deadLink]; for (let i = 0; i < group.keys.length; i++) { const key = group.keys[i]; const next = nextValues[i]; if (!next) continue; if (overwrite || !editConf(key)) { writeEditConf(key, next); } const colorInput = inputs[key]; if (!colorInput) continue; colorInput.value = editConf(key) || next; delete colorInput.dataset.unset; } } }; const refreshStylingPreview = () => Settings.refreshStylingPreviewFromDialog(); const colorHexInputs = dict(); const applyMergedSwatch = (hexInput, value) => { const merged = hexInput.parentElement; const rgb = value ? Settings.hexToRgb(value) : null; if (!rgb) { if (merged) merged.style.background = ''; hexInput.style.removeProperty('color'); hexInput.style.removeProperty('-webkit-text-fill-color'); return; } if (merged) merged.style.background = value; const onWhite = Settings.contrastRatio([255, 255, 255], rgb); const onBlack = Settings.contrastRatio([17, 17, 17], rgb); const textColor = onWhite >= onBlack ? '#fff' : '#111'; hexInput.style.setProperty('color', textColor, 'important'); hexInput.style.setProperty('-webkit-text-fill-color', textColor, 'important'); }; const syncColorHexInput = (baseKey) => { const colorInput = inputs[baseKey]; const hexInput = colorHexInputs[baseKey]; if (!colorInput || !hexInput) return; hexInput.value = colorInput.value || ''; hexInput.disabled = colorInput.disabled; hexInput.classList.remove('styling-color-hex-invalid'); applyMergedSwatch(hexInput, hexInput.value); }; const syncColorHexInputs = () => { for (const baseKey in colorHexInputs) syncColorHexInput(baseKey); }; for (const baseKey of hexEditableColorKeys) { const colorInput = inputs[baseKey]; if (!colorInput || colorInput.type !== 'color') continue; const label = colorInput.title || ''; const hexInput = $.el('input', { type: 'text', className: 'field styling-color-hex', placeholder: '#rrggbb', title: label ? `${label}: type a hex value` : 'Hex color, e.g. #ff5050', }); hexInput.maxLength = 7; hexInput.setAttribute('spellcheck', 'false'); const merged = $.el('span', { className: 'styling-color-merged', title: label, }); const trigger = $.el('span', { className: 'styling-color-trigger', title: label ? `${label}: open picker` : 'Pick a color', }); colorInput.replaceWith(merged); trigger.appendChild(colorInput); merged.appendChild(hexInput); merged.appendChild(trigger); colorHexInputs[baseKey] = hexInput; $.on(hexInput, 'input', () => { const raw = hexInput.value.trim(); const validPartial = /^#?[0-9a-f]{0,6}$/i.test(raw); const normalized = /^#?[0-9a-f]{6}$/i.test(raw) ? Settings.normalizeHexColorInput(raw) : null; hexInput.classList.toggle('styling-color-hex-invalid', !!raw && !validPartial); if (!normalized) return; colorInput.value = normalized; delete colorInput.dataset.unset; hexInput.value = normalized; colorInput.dispatchEvent(new Event('input', { bubbles: true })); }); $.on(hexInput, 'change', () => { const normalized = Settings.normalizeHexColorInput(hexInput.value); if (normalized) { colorInput.value = normalized; delete colorInput.dataset.unset; hexInput.value = normalized; colorInput.dispatchEvent(new Event('change', { bubbles: true })); } else { syncColorHexInput(baseKey); } }); $.on(colorInput, 'input', () => syncColorHexInput(baseKey)); $.on(colorInput, 'change', () => syncColorHexInput(baseKey)); } const syncCatalogHighlightControls = () => { const catalogEnabled = !!inputs['Enable Catalog Highlights']?.checked; for (const key of catalogHighlightKeys) { const enabled = catalogEnabled && !!inputs[key]?.checked; const controls = key === 'Catalog Highlight Own Posts' ? [ 'Catalog Highlight Own Color', 'Catalog Highlight Own Opacity', 'Catalog Highlight Own Background', 'Catalog Highlight Own Location', 'Catalog Highlight Own Border Width', 'Catalog Highlight Own Border Style', 'Catalog Highlight Own Glow', 'Catalog Highlight Own Glow Intensity', 'Catalog Highlight Own Text Mode', 'Catalog Highlight Own Text Color', 'Catalog Highlight Own Subject Color', 'Catalog Highlight Own Link Color', 'Catalog Highlight Own Quote Color', 'Catalog Highlight Own Dead Link Color', ] : [ 'Catalog Highlight Watched Color', 'Catalog Highlight Watched Opacity', 'Catalog Highlight Watched Background', 'Catalog Highlight Watched Location', 'Catalog Highlight Watched Border Width', 'Catalog Highlight Watched Border Style', 'Catalog Highlight Watched Glow', 'Catalog Highlight Watched Glow Intensity', 'Catalog Highlight Watched Text Mode', 'Catalog Highlight Watched Text Color', 'Catalog Highlight Watched Subject Color', 'Catalog Highlight Watched Link Color', 'Catalog Highlight Watched Quote Color', 'Catalog Highlight Watched Dead Link Color', ]; for (const controlKey of controls) { const control = inputs[controlKey]; if (control) control.disabled = !enabled; } } const filterRowOn = !!inputs['Catalog Highlight Filter Posts']?.checked; for (const controlKey of [ 'Catalog Highlight Filter Color', 'Catalog Highlight Filter Opacity', 'Catalog Highlight Filter Background', 'Catalog Highlight Filter Location', 'Catalog Highlight Filter Border Width', 'Catalog Highlight Filter Border Style', 'Catalog Highlight Filter Glow', 'Catalog Highlight Filter Glow Intensity', 'Catalog Highlight Filter Text Mode', 'Catalog Highlight Filter Text Color', 'Catalog Highlight Filter Subject Color', 'Catalog Highlight Filter Link Color', 'Catalog Highlight Filter Quote Color', 'Catalog Highlight Filter Dead Link Color', ]) { const control = inputs[controlKey]; if (control) control.disabled = !filterRowOn; } const gateGlow = (rowOn, glowKey, intensityKey) => { const intensity = inputs[intensityKey]; if (intensity) intensity.disabled = !(rowOn && !!inputs[glowKey]?.checked); }; gateGlow(catalogEnabled && !!inputs['Catalog Highlight Own Posts']?.checked, 'Catalog Highlight Own Glow', 'Catalog Highlight Own Glow Intensity'); gateGlow(catalogEnabled && !!inputs['Catalog Highlight Watched Threads']?.checked, 'Catalog Highlight Watched Glow', 'Catalog Highlight Watched Glow Intensity'); gateGlow(filterRowOn, 'Catalog Highlight Filter Glow', 'Catalog Highlight Filter Glow Intensity'); syncColorHexInputs(); }; const placeMarkerColorControls = () => { const markersOn = !!inputs['Scrollbar Markers']?.checked; for (const { type, onKey, matchKey, highlightKey } of markerControlRows) { if (!highlightKey) continue; const item = $(`[data-highlight-row="${type}"]`, section); if (!item) continue; const wrapper = $(`[data-marker-color="${type}"]`, item); const headSlot = $(`[data-mk-headslot="${type}"]`, item); const bodySlot = $(`[data-mk-bodyslot="${type}"]`, item); if (!wrapper || !headSlot || !bodySlot) continue; const open = item.classList.contains('styling-hl-open'); const rowOn = markersOn && !!inputs[onKey]?.checked; const matched = !!(matchKey && inputs[matchKey]?.checked); const target = !open && rowOn && !matched ? headSlot : bodySlot; if (wrapper.parentElement !== target) target.appendChild(wrapper); } }; const syncMarkerColorControls = () => { Settings.syncLinkedMarkerColors(inputs, editVariant()); const markersOn = !!inputs['Scrollbar Markers']?.checked; for (const { type, onKey, colorKey, opacityKey, matchKey } of markerControlRows) { const rowOn = markersOn && !!inputs[onKey]?.checked; const matched = !!(matchKey && inputs[matchKey]?.checked); const onInput = inputs[onKey]; if (onInput) onInput.disabled = !markersOn; if (matchKey && inputs[matchKey]) inputs[matchKey].disabled = !rowOn; const colorInput = inputs[colorKey]; if (colorInput) colorInput.disabled = !rowOn || matched; const opacityInput = inputs[opacityKey]; if (opacityInput) opacityInput.disabled = !rowOn; const cell = $(`[data-marker-color="${type}"]`, section); if (cell) cell.dataset.colorLinked = matched ? 'true' : 'false'; } placeMarkerColorControls(); syncColorHexInputs(); }; const syncThreadHighlightControls = () => { const masterOn = !!inputs['Enable Thread Highlights']?.checked; for (const { onKey, controls } of threadHighlightControlRows) { const enabled = masterOn && !!inputs[onKey]?.checked; for (const key of controls) { const control = inputs[key]; if (control) control.disabled = !enabled; } } syncColorHexInputs(); }; const syncTextColorControls = () => { const manualMode = (textColorModeSelect?.value || 'auto') === 'manual'; if (textColorManualTree) textColorManualTree.hidden = !manualMode; for (const key of textColorKeys) { const colorInput = inputs[key]; if (colorInput) colorInput.disabled = !manualMode; const clearButton = $(`[data-clear="${key}"]`, section); if (clearButton) clearButton.disabled = !manualMode; } }; const syncHighlightTextControls = () => { for (const group of highlightTextControlGroups) { const manual = groupTextMode(group) === 'manual'; const manualRoot = $(`[data-highlight-text-manual="${group.manualGroup}"]`, section); if (manualRoot) manualRoot.hidden = !manual; for (const key of group.keys) { const colorInput = inputs[key]; if (colorInput) colorInput.disabled = !manual; const clearButton = $(`[data-clear="${key}"]`, section); if (clearButton) clearButton.disabled = !manual; } } }; const setMatchTargetHighlight = (role, on) => { if (!role) return; const target = $(`[data-highlight-row="${role}"]`, section); if (!target) return; target.classList.toggle('styling-match-hover-target', on); }; for (const matchLabel of $$('[data-match-target]', section)) { const role = matchLabel.dataset.matchTarget || ''; $.on(matchLabel, 'mouseenter', () => setMatchTargetHighlight(role, true)); $.on(matchLabel, 'mouseleave', () => setMatchTargetHighlight(role, false)); $.on(matchLabel, 'focusin', () => setMatchTargetHighlight(role, true)); $.on(matchLabel, 'focusout', () => setMatchTargetHighlight(role, false)); } const accItems = $$('.styling-hl-acc-item', section); const updatePreviewStateFromRows = refreshStylingPreview; for (const head of $$('.styling-hl-acc-head', section)) { $.on(head, 'click', (e) => { const target = e.target; if (target.closest('.styling-hl-color')) return; const item = head.parentElement; const open = item.classList.contains('styling-hl-open'); for (const it of accItems) it.classList.remove('styling-hl-open'); if (!open) item.classList.add('styling-hl-open'); placeMarkerColorControls(); updatePreviewStateFromRows(); }); } const widthInputKeys = [ 'Highlight Own Edge Width', 'Highlight You Edge Width', 'Highlight Ghost Edge Width', 'Catalog Highlight Own Border Width', 'Catalog Highlight Watched Border Width', 'Catalog Highlight Filter Border Width', ]; const zeroWidthAllowedKeys = new Set([ 'Catalog Highlight Own Border Width', 'Catalog Highlight Watched Border Width', 'Catalog Highlight Filter Border Width', ]); const borderStyleInputKeys = new Set([ 'Highlight Own Border Style', 'Highlight You Border Style', 'Highlight Ghost Border Style', 'Catalog Highlight Own Border Style', 'Catalog Highlight Watched Border Style', 'Catalog Highlight Filter Border Style', 'Catalog Highlight Own Location', 'Catalog Highlight Watched Location', 'Catalog Highlight Filter Location', ]); for (const key of widthInputKeys) { const inp = inputs[key]; if (!inp) continue; const min = zeroWidthAllowedKeys.has(key) ? 0 : 1; const apply = () => { Settings.applyStylingVars(); refreshStylingPreview(); }; $.on(inp, 'input', apply); $.on(inp, 'change', () => { let n = parseInt(inp.value, 10); if (!Number.isFinite(n)) n = 3; n = Math.min(12, Math.max(min, n)); inp.value = String(n); writeEditConf(key, n); apply(); }); } const opacityReadouts = []; const fmtOpacity = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? n.toFixed(2) : v; }; const refreshOpacityReadouts = () => { for (const { range, out } of opacityReadouts) out.textContent = fmtOpacity(range.value); }; for (const range of $$('.styling-hl-octl input[type="range"]', section)) { const out = $.el('span', { className: 'styling-hl-valout' }); range.insertAdjacentElement('afterend', out); const upd = () => { out.textContent = fmtOpacity(range.value); }; $.on(range, 'input', upd); $.on(range, 'change', upd); opacityReadouts.push({ range, out }); } refreshOpacityReadouts(); const refreshUnsetColorInputs = () => { for (const key in inputs) { const inp = inputs[key]; if (inp.type !== 'color' || inp.dataset.unset !== '1') continue; if (highlightTextKeys.has(key)) { const group = highlightTextControlGroups.find(item => item.keys.includes(key)); if (group && groupTextMode(group) === 'manual') continue; } Settings.setColorInputValue(inp, key, ''); } }; if (textColorModeSelect) { $.on(textColorModeSelect, 'change', () => { writeEditConf('textColorMode', textColorModeSelect.value); syncTextColorControls(); syncAutoHighlightPreviewInputs(); Settings.applyStylingVars(); refreshStylingPreview(); }); } for (const group of highlightTextControlGroups) { const modeSelect = inputs[group.modeKey]; if (!modeSelect) continue; $.on(modeSelect, 'change', () => { writeEditConf(group.modeKey, modeSelect.value); const mode = Settings.resolveTextMode(modeSelect.value); if (mode === 'auto') { syncAutoHighlightPreviewInputs(); } else if (mode === 'manual') { seedManualHighlightTextColors(group, true); } syncHighlightTextControls(); Settings.applyStylingVars(); refreshStylingPreview(); }); } const items = dict(); for (name in inputs) { input = inputs[name]; if (name === 'Custom CSS') continue; items[input.name] = Conf[input.name]; const event = ((input.nodeName === 'SELECT') || ['checkbox', 'radio', 'color', 'range'].includes(input.type) || ((input.nodeName === 'TEXTAREA') && !(name in Settings))) ? 'change' : 'input'; const persist = $.cb[input.type === 'checkbox' ? 'checked' : 'value']; $.on(input, event, persist); if ((input.type === 'color' || input.type === 'range') && event !== 'input') { $.on(input, 'input', persist); } if (input.type === 'checkbox') { $.on(input, 'change', function () { setCheckedState(this); }); $.on(input, 'change', () => { syncAutoHighlightPreviewInputs(); Settings.applyStylingVars(); refreshStylingPreview(); }); if (catalogHighlightKeys.includes(name) || name === 'Catalog Highlight Own Glow' || name === 'Catalog Highlight Watched Glow' || name === 'Catalog Highlight Filter Posts' || name === 'Catalog Highlight Filter Glow') { $.on(input, 'change', syncCatalogHighlightControls); } if (markerToggleKeys.has(name)) { $.on(input, 'change', syncMarkerColorControls); } if (threadHighlightToggleKeys.has(name)) { $.on(input, 'change', syncThreadHighlightControls); } if (name === 'Enable Catalog Highlights') { $.on(input, 'change', syncCatalogHighlightControls); } if (markerRefreshKeys.has(name)) { $.on(input, 'change', () => $.event('RefreshScrollMarkers')); } } if (name in Settings) $.on(input, event, Settings[name]); if (input.type === 'color') { const applyColor = function () { delete this.dataset.unset; syncAutoHighlightPreviewInputs(); Settings.applyStylingVars(); refreshStylingPreview(); }; $.on(input, event, applyColor); if (event !== 'input') $.on(input, 'input', applyColor); } else if (input.type === 'range') { const applyRange = () => { syncAutoHighlightPreviewInputs(); Settings.applyStylingVars(); refreshStylingPreview(); }; $.on(input, event, applyRange); if (event !== 'input') $.on(input, 'input', applyRange); } else if (borderStyleInputKeys.has(name)) { $.on(input, 'change', () => { writeEditConf(name, input.value); Settings.applyStylingVars(); refreshStylingPreview(); }); } } Settings.primeResolvedStyleColorCache(Object.keys(inputs).filter(key => inputs[key].type === 'color')); const customCSS = inputs['Custom CSS']; customCSS.checked = Conf['Custom CSS']; inputs['usercss'].disabled = !Conf['Custom CSS']; $.on(customCSS, 'change', Settings.togglecss); Settings.initCustomCSSEditor(section, inputs['usercss']); const populateInputsFromLoaded = (loaded) => { const prevBgCache = Settings.styleBgCache; Settings.styleBgCache = prevBgCache || {}; try { Settings.getPostBaseBackground(); Settings.primeResolvedStyleColorCache(Object.keys(inputs).filter(key => inputs[key].type === 'color')); for (const storageKey in loaded) { const val = loaded[storageKey]; const baseName = Settings.styleKeyBase(storageKey); const inp = inputs[baseName]; if (!inp) continue; if (inp.type === 'checkbox') { inp.checked = !!val; setCheckedState(inp); } else if (inp.type === 'color') { Settings.setColorInputValue(inp, baseName, val); } else if (inp.type === 'range') { inp.value = (val === '' || val == null) ? '1' : String(val); } else if (baseName === 'siteStyle' && !val) { } else { inp.value = val ?? ''; } inp.hidden = false; if (baseName === 'siteStyle' && !val) continue; if (baseName in Settings) Settings[baseName].call(inp); } syncMarkerColorControls(); syncCatalogHighlightControls(); syncThreadHighlightControls(); syncTextColorControls(); syncHighlightTextControls(); seedManualHighlightTextColors(); syncAutoHighlightPreviewInputs(); Settings.applyStylingVars(); refreshUnsetColorInputs(); Settings.refreshCustomCSSEditor(section); syncColorHexInputs(); refreshOpacityReadouts(); refreshStylingPreview(); } finally { Settings.styleBgCache = prevBgCache; } }; $.get(items, populateInputsFromLoaded); for (const btn of $$('[data-clear]', section)) { $.on(btn, 'click', () => { const baseKey = btn.dataset.clear; const storageKey = Settings.styleVariantKeySet.has(baseKey) ? Settings.variantKey(baseKey) : baseKey; Conf[storageKey] = ''; $.set(storageKey, ''); Settings.applyStylingVars(); const target = inputs[baseKey]; if (target) { Settings.setColorInputValue(target, baseKey, ''); } syncAutoHighlightPreviewInputs(); refreshStylingPreview(); }); } const applyThemeDefaults = () => { for (const [baseKey, value] of themeDefaultSettings) { writeEditConf(baseKey, value); const input = inputs[baseKey]; if (!input) continue; if (input.type === 'checkbox') { input.checked = !!value; setCheckedState(input); } else if (input.type === 'color') { Settings.setColorInputValue(input, baseKey, value); } else if (input.type === 'range') { input.value = value === '' ? '1' : String(value); } else { input.value = String(value ?? ''); } } syncMarkerColorControls(); syncCatalogHighlightControls(); syncThreadHighlightControls(); syncTextColorControls(); syncHighlightTextControls(); syncAutoHighlightPreviewInputs(); Settings.applyStylingVars(); refreshUnsetColorInputs(); syncColorHexInputs(); refreshOpacityReadouts(); updatePreviewStateFromRows(); refreshSuggestedPalettesIfOpen(); $.event('RefreshScrollMarkers'); }; const themeDefaultPaletteColors = () => { const exprs = { own: 'var(--xt-border-highlight, #d83030)', you: 'var(--xt-border-highlight, #d83030)', ghost: '#888888', catalogOwn: 'var(--xt-border-highlight, #d83030)', catalogWatched: 'var(--xt-watched-border, rgba(255, 0, 0, .75))', }; const out = { own: '#000000', you: '#000000', ghost: '#888888', catalogOwn: '#000000', catalogWatched: '#000000' }; const scope = Settings.dialog || d.body; if (!scope) return out; const host = Settings.getStyleProbeHost(scope); const probes = {}; for (const slot in exprs) { const probe = $.el('span'); probe.style.color = exprs[slot]; probes[slot] = probe; $.add(host, probe); } for (const slot in exprs) out[slot] = Settings.toHexColor(window.getComputedStyle(probes[slot]).color) || out[slot]; host.textContent = ''; return out; }; const filterDefaultsBtn = $('#styling-filter-defaults', section); if (filterDefaultsBtn) { const filterDefaults = [ ['Catalog Highlight Filter Posts', true], ['Catalog Highlight Filter Color', ''], ['Catalog Highlight Filter Opacity', ''], ['Catalog Highlight Filter Background', false], ['Catalog Highlight Filter Location', 'image'], ['Catalog Highlight Filter Border Width', 2], ['Catalog Highlight Filter Border Style', 'solid'], ['Catalog Highlight Filter Glow', true], ['Catalog Highlight Filter Glow Intensity', 0.5], ['Catalog Highlight Filter Text Mode', 'default'], ['Catalog Highlight Filter Text Color', ''], ['Catalog Highlight Filter Subject Color', ''], ['Catalog Highlight Filter Link Color', ''], ['Catalog Highlight Filter Quote Color', ''], ['Catalog Highlight Filter Dead Link Color', ''], ]; $.on(filterDefaultsBtn, 'click', (e) => { e.preventDefault(); for (const [baseKey, value] of filterDefaults) { writeEditConf(baseKey, value); const input = inputs[baseKey]; if (!input) continue; if (input.type === 'checkbox') { input.checked = !!value; setCheckedState(input); } else if (input.type === 'color') { Settings.setColorInputValue(input, baseKey, value); } else if (input.type === 'range') { input.value = value === '' ? '1' : String(value); } else { input.value = String(value ?? ''); } } syncCatalogHighlightControls(); syncHighlightTextControls(); syncAutoHighlightPreviewInputs(); Settings.applyStylingVars(); refreshUnsetColorInputs(); syncColorHexInputs(); refreshOpacityReadouts(); updatePreviewStateFromRows(); }); } const paletteSuggestionRoot = $('#styling-palette-suggestions', section); const suggestPalettesBtn = $('#styling-suggest-palettes', section); const savedPaletteNameInput = $('#styling-saved-palette-name', section); const savePaletteBtn = $('#styling-save-palette', section); const savedPalettesList = $('#styling-saved-palettes-list', section); let suggestedPaletteBatch = 0; let suggestedRandomMode = false; const paletteStateMap = [ ['own', 'Highlight Own Color', 'Thread: your post'], ['you', 'Highlight You Color', 'Thread: quotes you'], ['ghost', 'Highlight Ghost Color', 'Thread: ghost post'], ['catalogOwn', 'Catalog Highlight Own Color', 'Catalog: your post'], ['catalogWatched', 'Catalog Highlight Watched Color', 'Catalog: watched thread'], ]; const markerPaletteMap = [ ['own', 'Scroll Marker Own Color', 'Highlight Own Color', 'Scroll Marker Own Match Highlight', 'Marker: your post'], ['you', 'Scroll Marker You Color', 'Highlight You Color', 'Scroll Marker You Match Highlight', 'Marker: quotes you'], ['ghost', 'Scroll Marker Ghost Color', 'Highlight Ghost Color', 'Scroll Marker Ghost Match Highlight', 'Marker: ghost post'], ]; const resolveSlotColor = (baseKey) => { const inputColor = inputs[baseKey]?.value || ''; const storedColor = editConf(baseKey) || ''; return (Settings.toHexColor(inputColor) || Settings.toHexColor(storedColor) || Settings.resolvedColorForKey(baseKey) || '#000000').toLowerCase(); }; const readCurrentPaletteColors = () => { const out = { own: '#000000', you: '#000000', ghost: '#000000', catalogOwn: '#000000', catalogWatched: '#000000', }; for (const [slot, baseKey] of paletteStateMap) out[slot] = resolveSlotColor(baseKey); return out; }; const readCurrentPaletteMarkers = () => { const markers = {}; for (const [slot, colorKey, highlightKey, matchKey] of markerPaletteMap) { if (editConf(matchKey)) continue; const markerColor = resolveSlotColor(colorKey); if (markerColor && markerColor !== resolveSlotColor(highlightKey)) markers[slot] = markerColor; } return markers; }; const persistPalette = (name, colors, markers) => { const list = Settings.savedHighlightPaletteList(); const existing = list.findIndex((p) => p.name.toLowerCase() === name.toLowerCase()); const entry = { name, colors, markers }; if (existing >= 0) list[existing] = entry; else list.unshift(entry); Settings.setSavedHighlightPalettes(list); renderSavedPalettes(); }; function applySuggestedPalette(palette) { for (const [slot, baseKey] of paletteStateMap) { const color = palette.colors[slot]; if (!color) continue; writeEditConf(baseKey, color); const inp = inputs[baseKey]; if (inp) Settings.setColorInputValue(inp, baseKey, color); } if (palette.markers) { for (const [slot, colorKey, , matchKey] of markerPaletteMap) { const color = palette.markers[slot]; const matchInp = inputs[matchKey]; if (color) { writeEditConf(matchKey, false); if (matchInp) { matchInp.checked = false; setCheckedState(matchInp); } writeEditConf(colorKey, color); const colorInp = inputs[colorKey]; if (colorInp) Settings.setColorInputValue(colorInp, colorKey, color); } else { writeEditConf(matchKey, true); if (matchInp) { matchInp.checked = true; setCheckedState(matchInp); } } } } syncMarkerColorControls(); syncAutoHighlightPreviewInputs(); Settings.applyStylingVars(); refreshStylingPreview(); } const buildRandomPalettes = (count = 6) => { const out = []; for (let i = 0; i < count; i++) { out.push({ name: `Random ${i + 1}`, colors: { own: Settings.randomHighlightColor(), you: Settings.randomHighlightColor(), ghost: Settings.randomHighlightColor(), catalogOwn: Settings.randomHighlightColor(), catalogWatched: Settings.randomHighlightColor(), }, }); } return out; }; function renderSuggestedPalettes() { if (!paletteSuggestionRoot) return; const suggested = suggestedRandomMode ? null : Settings.suggestedHighlightPalettes(editVariant(), suggestedPaletteBatch); const palettes = suggested ? suggested.palettes : buildRandomPalettes(); paletteSuggestionRoot.textContent = ''; const header = $.el('div', { className: 'styling-palette-header' }); const title = $.el('div', { className: 'styling-palette-title', textContent: suggested ? `Suggested palettes for ${suggested.profile.label}` : 'Random palettes', }); const note = $.el('div', { className: 'styling-palette-note note', textContent: suggested ? suggested.profile.note : 'Freshly generated random palettes. Click Randomize for another set.', }); const randomize = $.el('button', { type: 'button', textContent: 'Randomize', }); $.on(randomize, 'click', () => { suggestedRandomMode = true; renderSuggestedPalettes(); }); $.add(header, [title, note, randomize]); if (suggestedRandomMode) { const back = $.el('button', { type: 'button', textContent: 'Back to suggested', }); $.on(back, 'click', () => { suggestedRandomMode = false; renderSuggestedPalettes(); }); $.add(header, back); } $.add(paletteSuggestionRoot, header); const list = $.el('div', { className: 'styling-palette-list' }); for (const palette of palettes) { const row = $.el('div', { className: 'styling-palette-row' }); const apply = $.el('button', { type: 'button', textContent: 'Apply', }); $.on(apply, 'click', () => applySuggestedPalette(palette)); const save = $.el('button', { type: 'button', textContent: 'Save', title: `Save "${palette.name}" to your palettes`, }); $.on(save, 'click', () => { persistPalette(palette.name, palette.colors, readCurrentPaletteMarkers()); save.textContent = 'Saved'; save.disabled = true; }); const name = $.el('div', { className: 'styling-palette-name', textContent: palette.name }); const swatches = $.el('div', { className: 'styling-palette-swatches' }); for (const [slot, , label] of paletteStateMap) { const color = palette.colors[slot]; const swatch = $.el('span', { className: 'styling-palette-swatch', title: `${label}: ${color}`, }); swatch.style.backgroundColor = color; $.add(swatches, swatch); } $.add(row, [name, apply, save, swatches]); $.add(list, row); } $.add(paletteSuggestionRoot, list); } function renderSavedPalettes() { if (!savedPalettesList) return; savedPalettesList.textContent = ''; { const row = $.el('div', { className: 'styling-saved-palette-row styling-saved-palette-row-theme' }); const apply = $.el('button', { type: 'button', textContent: 'Apply' }); $.on(apply, 'click', () => applyThemeDefaults()); const name = $.el('div', { className: 'styling-palette-name', textContent: 'Theme defaults' }); const spacer = $.el('span', { className: 'styling-palette-rowspacer' }); const swatches = $.el('div', { className: 'styling-palette-swatches' }); const themeColors = themeDefaultPaletteColors(); for (const [slot, , label] of paletteStateMap) { const color = themeColors[slot]; const swatch = $.el('span', { className: 'styling-palette-swatch', title: `${label}: ${color}` }); swatch.style.backgroundColor = color; $.add(swatches, swatch); } $.add(row, [name, apply, spacer, swatches]); $.add(savedPalettesList, row); } const palettes = Settings.savedHighlightPaletteList(); if (!palettes.length) { const empty = $.el('div', { className: 'styling-palette-note note', textContent: 'No saved palettes yet.', }); $.add(savedPalettesList, empty); return; } for (const [index, palette] of palettes.entries()) { const row = $.el('div', { className: 'styling-saved-palette-row' }); const apply = $.el('button', { type: 'button', textContent: 'Apply', }); const remove = $.el('button', { type: 'button', textContent: 'Delete', title: `Delete ${palette.name}`, }); $.on(apply, 'click', () => applySuggestedPalette(palette)); $.on(remove, 'click', () => { const list = Settings.savedHighlightPaletteList(); list.splice(index, 1); Settings.setSavedHighlightPalettes(list); renderSavedPalettes(); }); const name = $.el('div', { className: 'styling-palette-name', textContent: palette.name }); const swatches = $.el('div', { className: 'styling-palette-swatches' }); for (const [slot, , label] of paletteStateMap) { const color = palette.colors[slot]; const swatch = $.el('span', { className: 'styling-palette-swatch', title: `${label}: ${color}`, }); swatch.style.backgroundColor = color; $.add(swatches, swatch); } for (const [slot, , , , label] of markerPaletteMap) { const color = palette.markers?.[slot]; if (!color) continue; const swatch = $.el('span', { className: 'styling-palette-swatch styling-palette-swatch-marker', title: `${label}: ${color}`, }); swatch.style.backgroundColor = color; $.add(swatches, swatch); } $.add(row, [name, apply, remove, swatches]); $.add(savedPalettesList, row); } } function refreshSuggestedPalettesIfOpen() { if (!paletteSuggestionRoot || paletteSuggestionRoot.hidden) return; suggestedPaletteBatch = 0; renderSuggestedPalettes(); } if (suggestPalettesBtn && paletteSuggestionRoot) { $.on(suggestPalettesBtn, 'click', () => { paletteSuggestionRoot.hidden = !paletteSuggestionRoot.hidden; suggestPalettesBtn.textContent = paletteSuggestionRoot.hidden ? 'Suggest palettes' : 'Hide palettes'; if (!paletteSuggestionRoot.hidden) { suggestedPaletteBatch = 0; suggestedRandomMode = false; renderSuggestedPalettes(); } }); } if (savePaletteBtn && savedPaletteNameInput) { const saveCurrentPalette = () => { const name = savedPaletteNameInput.value.trim(); if (!name) { savedPaletteNameInput.focus(); return; } persistPalette(name, readCurrentPaletteColors(), readCurrentPaletteMarkers()); }; $.on(savePaletteBtn, 'click', saveCurrentPalette); $.on(savedPaletteNameInput, 'keydown', (e) => { if (e.key !== 'Enter') return; e.preventDefault(); saveCurrentPalette(); }); } renderSavedPalettes(); $.get({ savedHighlightPalettes: Conf['savedHighlightPalettes'] }, ({ savedHighlightPalettes }) => { Conf['savedHighlightPalettes'] = savedHighlightPalettes; renderSavedPalettes(); }); const siteStyleInput = inputs['siteStyle']; if (siteStyleInput) { $.on(siteStyleInput, 'change', refreshSuggestedPalettesIfOpen); } const variantBar = $('.styling-variant-bar', section); const variantHint = $('.styling-variant-hint', section); const tabsByVariant = { sfw: $('.styling-variant-tabs [data-styling-variant="sfw"]', section), nsfw: $('.styling-variant-tabs [data-styling-variant="nsfw"]', section), }; const updateVariantHint = () => { if (!variantHint) return; const board = Settings.getBoardVariant(); const mode = Conf['sfwNsfwMode']; const editing = Settings.stylingEditingVariant || board; let reason; if (mode === 'sfw') reason = 'SFW (forced everywhere)'; else if (mode === 'nsfw') reason = 'NSFW (forced everywhere)'; else if (!g.boardID) reason = 'SFW (no board context)'; else reason = `${board.toUpperCase()} (board ${g.boardID} is ${board === 'sfw' ? 'worksafe' : 'NSFW'})`; const editingNote = editing === board ? '' : ` — you are editing the ${editing.toUpperCase()} variant, which is not currently applied`; variantHint.textContent = `Active variant: ${reason}${editingNote}.`; }; const updateVariantTabsSelected = () => { const v = Settings.stylingEditingVariant || 'sfw'; for (const variant of ['sfw', 'nsfw']) { const tab = tabsByVariant[variant]; if (!tab) continue; const selected = variant === v; tab.classList.toggle('settings-subnav-tab-selected', selected); tab.setAttribute('aria-selected', selected ? 'true' : 'false'); } }; const stylingHost = section.closest('.section-styling') || section; const updateVariantDecoration = (variant) => { const shortLabel = variant.toUpperCase(); if (variantBar) variantBar.dataset.editingVariant = variant; stylingHost.dataset.editingVariant = variant; for (const detail of $$('details[data-variant-aware="true"]', section)) { detail.dataset.variantLabel = shortLabel; const summaryText = $('.styling-section-summary-text', detail); if (summaryText) { summaryText.dataset.variantLabel = shortLabel; const oldBadge = $('summary > .styling-variant-badge', detail); if (oldBadge) $.rm(oldBadge); continue; } const summary = $('summary', detail); if (!summary) continue; let badge = $(':scope > .styling-variant-badge', summary); if (!badge) { badge = $.el('span', { className: 'styling-variant-badge' }); const firstAction = Array.from(summary.children).find(child => child.matches('button, input, select, textarea, label, [data-summary-action]')); summary.insertBefore(badge, firstAction || null); } badge.textContent = shortLabel; } }; const switchEditingVariant = (variant) => { if (Settings.stylingEditingVariant === variant) return; Settings.stylingEditingVariant = variant; renameVariantInputs(variant); updateVariantDecoration(variant); const refetch = dict(); for (const baseKey of styleVariantKeys) { const inp = inputs[baseKey]; if (!inp) continue; refetch[inp.name] = Conf[inp.name]; } $.get(refetch, (loaded) => { populateInputsFromLoaded(loaded); updateVariantTabsSelected(); updateVariantHint(); refreshSuggestedPalettesIfOpen(); }); }; for (const variant of ['sfw', 'nsfw']) { const tab = tabsByVariant[variant]; if (!tab) continue; tab.setAttribute('role', 'tab'); $.on(tab, 'click', () => switchEditingVariant(variant)); } const applyModeVisibility = () => { if (variantBar) variantBar.dataset.mode = Conf['sfwNsfwMode'] || 'auto'; }; const modeSelect = inputs['sfwNsfwMode']; if (modeSelect) { $.on(modeSelect, 'change', () => { const mode = Conf['sfwNsfwMode']; if ((mode === 'sfw' || mode === 'nsfw') && Settings.stylingEditingVariant !== mode) { switchEditingVariant(mode); } applyModeVisibility(); updateVariantHint(); Settings.applyStylingVars(); if (Conf['Custom CSS']) CustomCSS.update(); $.event('CustomSiteThemeChanged'); $.event('RefreshScrollMarkers'); refreshSuggestedPalettesIfOpen(); }); } const initialMode = Conf['sfwNsfwMode']; if ((initialMode === 'sfw' || initialMode === 'nsfw') && Settings.stylingEditingVariant !== initialMode) { Settings.stylingEditingVariant = initialMode; } updateVariantTabsSelected(); updateVariantHint(); updateVariantDecoration(Settings.stylingEditingVariant || 'sfw'); applyModeVisibility(); Settings.applyStylingVars(); }, stylingPreviewSampleText() { const sample = $('.thread .postMessage, .postContainer .postMessage', d.body); const fallback = 'Sample thread text. Adjust settings above to see post colors update live.'; if (!sample) return fallback; const text = (sample.textContent || '').replace(/\s+/g, ' ').trim(); if (!text) return fallback; return text.length > 180 ? `${text.slice(0, 177)}...` : text; }, stylingPreviewSampleMessageHTML() { return [ '>greentext sample
    ', 'Regular text with a ', 'link, ', '>>1213499548, ', 'and a >>1213000000 dead link.' ].join(''); }, stylingPreviewPostHTML({ postID, extraClass = '', author = 'Anonymous', subject = '', message = '', messageHTML = '', }) { const subjectHTML = subject ? `${E(subject)} ` : ''; const renderedMessage = messageHTML || E(message || Settings.stylingPreviewSampleText()); const classes = `postContainer replyContainer styling-preview-post ${extraClass}`.trim(); return `
    >>
    ${renderedMessage}
    `; }, stylingPreviewCatalogThreadHTML({ threadID, postID, extraThreadClass = '', extraContainerClass = '', extraPostClass = '', subject = '', message = '', messageHTML = '', summary = '', excerpt = '', }) { const threadClasses = `thread catalog-thread ${extraThreadClass}`.trim(); const containerClasses = `postContainer catalog-container ${extraContainerClass}`.trim(); const postClasses = `post catalog-post ${extraPostClass}`.trim(); const safeSubject = E(subject || 'Catalog subject preview'); const renderedMessage = messageHTML || E(message || Settings.stylingPreviewSampleText()); const safeSummary = E(summary || '4 posts and 2 image replies'); const safeExcerpt = E(excerpt || 'recent reply preview'); return `
    12 / 8 / 1
    ${renderedMessage}
    ${safeSummary}
    `; }, stylingPreviewThreadState(state = 'default') { const messageHTML = [ 'Sample post text with a ', 'regular link, ', '>>1213499548, ', 'and a >>1213000000 dead link.', '
    >greentext sample' ].join(''); const states = { default: { subject: 'Default post', extraClass: '', messageHTML }, own: { subject: 'Your post', extraClass: 'yourPost', messageHTML }, you: { subject: 'Quotes you', extraClass: 'quotesYou', messageHTML }, ghost: { subject: 'Ghost post', extraClass: 'from-archive', messageHTML }, }; return states[state] || states.default; }, stylingPreviewCatalogState(state = 'default') { const sampleMessageHTML = Settings.stylingPreviewSampleMessageHTML(); const states = { default: { subject: 'Default catalog', extraThreadClass: '', extraContainerClass: '', extraPostClass: '', messageHTML: sampleMessageHTML, summary: '4 posts and 2 image replies', excerpt: 'recent reply preview', }, 'catalog-own': { subject: 'Your catalog post', extraThreadClass: 'xtcat-own', extraContainerClass: 'xtcat-own', extraPostClass: 'xtcat-own', messageHTML: sampleMessageHTML, summary: 'You replied in this thread', excerpt: 'your reply preview', }, 'catalog-watched': { subject: 'Watched catalog', extraThreadClass: 'xtcat-watched', extraContainerClass: '', extraPostClass: '', messageHTML: sampleMessageHTML, summary: 'Watched thread preview', excerpt: 'watched reply preview', }, 'catalog-filter': { subject: 'Filtered thread', extraThreadClass: 'xtcat-filter', extraContainerClass: 'xtcat-filter', extraPostClass: 'xtcat-filter', messageHTML: sampleMessageHTML, summary: 'Filtered thread preview', excerpt: 'filtered reply preview', } }; return states[state] || states.default; }, refreshStylingPreviewFromDialog() { const editVariant = Settings.stylingEditingVariant || Settings.getBoardVariant(); const storageKeyFor = (name) => Settings.styleVariantKeySet.has(name) ? Settings.variantKey(name, editVariant) : name; const findInput = (name) => { if (!Settings.dialog) return null; return $(`[name="${name}"], [name="${storageKeyFor(name)}"]`, Settings.dialog); }; const readConf = (name) => { const storageKey = storageKeyFor(name); return Conf[storageKey] == null ? Conf[name] : Conf[storageKey]; }; const readChecked = (name, fallback = false) => { const input = findInput(name); if (input) return !!input.checked; const value = readConf(name); return value == null ? fallback : !!value; }; const readOpacity = (name) => { const input = findInput(name); const value = input ? input.value : readConf(name); if (value === '' || value == null) return 1; const opacity = parseFloat(String(value)); return Number.isFinite(opacity) ? $.minmax(opacity, 0, 1) : 1; }; const readValue = (name) => { const input = findInput(name); if (input) return input.value; const value = readConf(name); return value == null ? '' : String(value); }; if (!Settings.dialog) return; const threadIDs = { own: 503286550, you: 503286551, ghost: 503286552 }; const catalogIDs = { 'catalog-own': 503286560, 'catalog-watched': 503286561, 'catalog-filter': 503286562, }; const background = Settings.resolveCanvasBackgroundStyle(); for (const inline of $$('[data-preview-inline]', Settings.dialog)) { const row = inline.dataset.previewInline || ''; inline.dataset.highlightOwn = 'true'; inline.dataset.highlightYou = 'true'; inline.dataset.highlightGhost = 'true'; inline.dataset.highlightCatalogOwn = 'true'; inline.dataset.highlightCatalogWatched = 'true'; inline.dataset.edgeOwn = readChecked('Highlight Own Background', false) ? 'false' : 'true'; inline.dataset.edgeYou = readChecked('Highlight You Background', false) ? 'false' : 'true'; inline.dataset.edgeGhost = readChecked('Highlight Ghost Background', false) ? 'false' : 'true'; inline.dataset.edgeCatalogOwn = readChecked('Catalog Highlight Own Background', false) ? 'false' : 'true'; inline.dataset.edgeCatalogWatched = readChecked('Catalog Highlight Watched Background', false) ? 'false' : 'true'; inline.dataset.textCatalogOwn = (inline.dataset.edgeCatalogOwn !== 'true' && readOpacity('Catalog Highlight Own Opacity') > 0) ? 'true' : 'false'; inline.dataset.textCatalogWatched = (inline.dataset.edgeCatalogWatched !== 'true' && readOpacity('Catalog Highlight Watched Opacity') > 0) ? 'true' : 'false'; inline.dataset.locCatalogOwn = readValue('Catalog Highlight Own Location') === 'image' ? 'image' : 'tile'; inline.dataset.locCatalogWatched = readValue('Catalog Highlight Watched Location') === 'image' ? 'image' : 'tile'; inline.dataset.locCatalogFilter = readValue('Catalog Highlight Filter Location') === 'image' ? 'image' : 'tile'; inline.dataset.glowCatalogOwn = readChecked('Catalog Highlight Own Glow', false) ? 'true' : 'false'; inline.dataset.glowCatalogWatched = readChecked('Catalog Highlight Watched Glow', false) ? 'true' : 'false'; const container = $('.styling-preview-container', inline); if (container && !container.firstElementChild) { if (threadIDs[row] != null) { const state = Settings.stylingPreviewThreadState(row); container.innerHTML = `
    ${Settings.stylingPreviewPostHTML({ postID: threadIDs[row], ...state })}
    `; } else if (catalogIDs[row] != null) { const state = Settings.stylingPreviewCatalogState(row); const id = catalogIDs[row]; container.innerHTML = `
    ${Settings.stylingPreviewCatalogThreadHTML({ threadID: id, postID: id, ...state })}
    `; } } for (const pane of $$('.styling-preview-thread, .styling-preview-catalog', inline)) { Settings.applyBackgroundStyle(pane, background); } } }, initCustomCSSEditor(section, textarea) { if (!textarea) return; const liveDetach = Settings.detached['settings.customCSSPanel']; if (liveDetach) { const freshDetails = $('.styling-custom-css', section); const freshButton = $('#custom-css-detach', section); if (freshDetails && freshButton) liveDetach.adopt(freshDetails, freshButton); return; } textarea.wrap = 'off'; textarea.spellcheck = false; textarea.hidden = false; const editor = $('.custom-css-editor', section); const highlight = $('.custom-css-highlight', section); const themeSelect = $('#custom-css-theme', section); const bracketToggle = $('#custom-css-bracket-highlight', section); const autocompleteToggle = $('#custom-css-autocomplete', section); const formatButton = $('#custom-css-format', section); if (!editor || !highlight || !themeSelect || !bracketToggle || !autocompleteToggle) return; const gutter = $('.custom-css-gutter', editor); const gutterInner = gutter ? $('.custom-css-gutter-inner', gutter) : null; const lineNumbers = gutterInner ? $('.custom-css-linenumbers', gutterInner) : null; const colorInput = $('.ccss-color-input', editor); const syncScroll = () => { highlight.scrollTop = textarea.scrollTop; highlight.scrollLeft = textarea.scrollLeft; if (gutterInner) gutterInner.style.transform = `translateY(${-textarea.scrollTop}px)`; }; const highlightOffsetFromPoint = (clientX, clientY) => { let node = null; let offset = 0; const caretPositionFromPoint = d.caretPositionFromPoint; const caretRangeFromPoint = d.caretRangeFromPoint; if (caretPositionFromPoint) { const pos = caretPositionFromPoint.call(d, clientX, clientY); node = pos?.offsetNode || null; offset = pos?.offset || 0; } else if (caretRangeFromPoint) { const range = caretRangeFromPoint.call(d, clientX, clientY); node = range?.startContainer || null; offset = range?.startOffset || 0; } if (!node || !highlight.contains(node)) return null; let count = 0; const walker = d.createTreeWalker(highlight, NodeFilter.SHOW_TEXT); for (let cur = walker.nextNode(); cur; cur = walker.nextNode()) { if (cur === node) return count + offset; count += cur.textContent?.length || 0; } return null; }; const focusEditorSurface = (e) => { const target = e.target; if (!target || target === textarea || target.closest('.ccss-swatch, .ccss-color-input')) return; if (textarea.disabled) return; const pointer = e; const offset = highlightOffsetFromPoint(pointer.clientX, pointer.clientY); e.preventDefault(); textarea.focus(); if (offset != null) { const pos = Math.min(textarea.value.length, Math.max(0, offset)); textarea.setSelectionRange(pos, pos); if (bracketToggle.checked) Settings.renderCustomCSSHighlight(textarea, highlight); } }; editor.addEventListener('pointerdown', focusEditorSurface, true); editor.addEventListener('mousedown', focusEditorSurface, true); const COLOR_RE = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})\b|(?:rgba?|hsla?)\([^)]*\)/gi; const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n))); const toHex2 = (n) => clamp255(n).toString(16).padStart(2, '0'); const rgbToHex = (r, g, b) => `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`; const fmtA = (a) => String(Math.round(a * 1000) / 1000); const hslToRgb = (h, s, l) => { h = (((h % 360) + 360) % 360) / 360; if (s === 0) { const v = l * 255; return { r: v, g: v, b: v }; } const hue = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; return { r: hue(p, q, h + 1 / 3) * 255, g: hue(p, q, h) * 255, b: hue(p, q, h - 1 / 3) * 255 }; }; const rgbToHsl = (r, g, b) => { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h = 0, s = 0; const l = (max + min) / 2; if (max !== min) { const dd = max - min; s = l > 0.5 ? dd / (2 - max - min) : dd / (max + min); h = max === r ? (g - b) / dd + (g < b ? 6 : 0) : max === g ? (b - r) / dd + 2 : (r - g) / dd + 4; h /= 6; } return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }; }; const parseColor = (raw) => { const str = raw.trim(); let m; if ((m = str.match(/^#([0-9a-f]{3,8})$/i))) { const h = m[1]; const hx = (a, b) => parseInt(h.slice(a, b), 16); if (h.length === 3) return { r: hx(0, 1) * 17, g: hx(1, 2) * 17, b: hx(2, 3) * 17, a: undefined, format: 'hex' }; if (h.length === 4) return { r: hx(0, 1) * 17, g: hx(1, 2) * 17, b: hx(2, 3) * 17, a: hx(3, 4) * 17 / 255, format: 'hexa' }; if (h.length === 6) return { r: hx(0, 2), g: hx(2, 4), b: hx(4, 6), a: undefined, format: 'hex' }; if (h.length === 8) return { r: hx(0, 2), g: hx(2, 4), b: hx(4, 6), a: hx(6, 8) / 255, format: 'hexa' }; return null; } if ((m = str.match(/^rgba?\(([^)]*)\)$/i))) { const p = m[1].split(/[,/\s]+/).filter(Boolean); if (p.length < 3) return null; const n = (s) => s.endsWith('%') ? parseFloat(s) * 255 / 100 : parseFloat(s); const r = n(p[0]), g = n(p[1]), b = n(p[2]); if ([r, g, b].some(isNaN)) return null; const a = p[3] != null ? (p[3].endsWith('%') ? parseFloat(p[3]) / 100 : parseFloat(p[3])) : undefined; return { r, g, b, a, format: a == null ? 'rgb' : 'rgba' }; } if ((m = str.match(/^hsla?\(([^)]*)\)$/i))) { const p = m[1].split(/[,/\s]+/).filter(Boolean); if (p.length < 3) return null; const h = parseFloat(p[0]), s = parseFloat(p[1]) / 100, l = parseFloat(p[2]) / 100; if ([h, s, l].some(isNaN)) return null; const a = p[3] != null ? (p[3].endsWith('%') ? parseFloat(p[3]) / 100 : parseFloat(p[3])) : undefined; const c = hslToRgb(h, s, l); return { r: c.r, g: c.g, b: c.b, a, format: a == null ? 'hsl' : 'hsla' }; } return null; }; const formatColor = (format, r, g, b, a) => { r = clamp255(r); g = clamp255(g); b = clamp255(b); switch (format) { case 'hexa': return rgbToHex(r, g, b) + toHex2((a ?? 1) * 255); case 'rgb': return `rgb(${r}, ${g}, ${b})`; case 'rgba': return `rgba(${r}, ${g}, ${b}, ${fmtA(a ?? 1)})`; case 'hsl': { const c = rgbToHsl(r, g, b); return `hsl(${c.h}, ${c.s}%, ${c.l}%)`; } case 'hsla': { const c = rgbToHsl(r, g, b); return `hsla(${c.h}, ${c.s}%, ${c.l}%, ${fmtA(a ?? 1)})`; } default: return rgbToHex(r, g, b); } }; let activeSwatch = null; const openColorPicker = (sw) => { if (!colorInput) return; activeSwatch = sw; const cur = parseColor(textarea.value.slice(+sw.dataset.start, +sw.dataset.end)); colorInput.value = cur ? rgbToHex(cur.r, cur.g, cur.b) : '#000000'; const r = sw.getBoundingClientRect(); colorInput.style.left = `${r.left}px`; colorInput.style.top = `${r.bottom}px`; colorInput.click(); }; let charWidth = 0; const measureCharW = () => { if (charWidth) return charWidth; const probe = $.el('span', { textContent: '0000000000' }); probe.style.cssText = 'position:absolute;top:-9999px;left:0;visibility:hidden;white-space:pre;font:var(--custom-css-font)'; editor.appendChild(probe); const w = probe.getBoundingClientRect().width; probe.remove(); if (w > 0) charWidth = w / 10; return charWidth || 7.2; }; const makeSwatch = (c) => { const sw = $.el('button', { className: 'ccss-swatch', type: 'button' }); sw.style.background = c.hex; sw.dataset.start = String(c.start); sw.dataset.end = String(c.end); sw.dataset.format = c.format; sw.dataset.alpha = c.alpha; sw.title = c.title; $.on(sw, 'mousedown', (e) => e.preventDefault()); $.on(sw, 'click', (e) => { e.preventDefault(); openColorPicker(sw); }); return sw; }; const swatchFan = $.el('div', { className: 'ccss-swatch-fan' }); swatchFan.dataset.show = 'false'; $.add(editor, swatchFan); let fanHideTimer = 0; let fanOpenDeck = null; const hideSwatchFan = () => { clearTimeout(fanHideTimer); swatchFan.dataset.show = 'false'; fanOpenDeck = null; }; const showSwatchFan = (deck) => { clearTimeout(fanHideTimer); const colors = deck._ccssColors; if (!colors) return; swatchFan.textContent = ''; colors.forEach((c, i) => { const card = makeSwatch(c); card.style.setProperty('--ccss-fan-i', String(i)); $.add(swatchFan, card); }); const r = deck.getBoundingClientRect(); swatchFan.style.left = `${r.left}px`; swatchFan.style.top = `${r.top}px`; swatchFan.dataset.show = 'true'; fanOpenDeck = deck; }; $.on(swatchFan, 'mouseenter', () => clearTimeout(fanHideTimer)); $.on(swatchFan, 'mouseleave', () => { fanHideTimer = window.setTimeout(hideSwatchFan, 180); }); const renderSwatches = () => { if (!gutterInner) return; hideSwatchFan(); for (const old of gutterInner.querySelectorAll('.ccss-swatch')) old.remove(); const text = textarea.value; const ignored = Settings.customCSSIgnoredCharMask(text); const cs = window.getComputedStyle(textarea); const lineHeight = parseFloat(cs.lineHeight) || 17; const padTop = parseFloat(cs.paddingTop) || 8; if (lineNumbers) { let lineCount = 1; for (let i = 0; i < text.length; i++) if (text[i] === '\n') lineCount++; let nums = ''; for (let n = 1; n <= lineCount; n++) nums += `${n}\n`; lineNumbers.textContent = nums; lineNumbers.style.top = `${padTop}px`; const gutterW = Math.ceil(`${lineCount}`.length * measureCharW()) + 24; editor.style.setProperty('--ccss-gutter-w', `${gutterW}px`); editor.style.setProperty('--ccss-pad-l', `${gutterW + 4}px`); } const byLine = new Map(); let line = 0, scan = 0; COLOR_RE.lastIndex = 0; let m; while ((m = COLOR_RE.exec(text))) { const start = m.index; while (scan < start) { if (text[scan] === '\n') line++; scan++; } if (ignored[start]) continue; const parsed = parseColor(m[0]); if (!parsed) continue; const entry = { start, end: start + m[0].length, format: parsed.format, alpha: parsed.a == null ? '' : String(parsed.a), hex: rgbToHex(parsed.r, parsed.g, parsed.b), title: m[0], }; const list = byLine.get(line); if (list) { list.push(entry); } else { byLine.set(line, [entry]); } } for (const [ln, list] of byLine) { const top = padTop + ln * lineHeight + (lineHeight - 13) / 2; if (list.length === 1) { const sw = makeSwatch(list[0]); sw.style.top = `${top}px`; $.add(gutterInner, sw); continue; } const deck = $.el('button', { className: 'ccss-swatch ccss-deck', type: 'button' }); deck.style.top = `${top}px`; const bands = list.slice(0, 3); const step = 100 / bands.length; deck.style.background = `linear-gradient(90deg, ${bands.map((c, i) => `${c.hex} ${i * step}% ${(i + 1) * step}%`).join(', ')})`; deck.dataset.count = String(list.length); deck.title = `${list.length} colors on this line`; deck._ccssColors = list; $.on(deck, 'mousedown', (e) => e.preventDefault()); $.on(deck, 'mouseenter', () => showSwatchFan(deck)); $.on(deck, 'mouseleave', () => { fanHideTimer = window.setTimeout(hideSwatchFan, 180); }); $.on(deck, 'click', (e) => { e.preventDefault(); if (fanOpenDeck === deck) { hideSwatchFan(); } else { showSwatchFan(deck); } }); $.add(gutterInner, deck); } }; if (colorInput) $.on(colorInput, 'change', () => { const sw = activeSwatch; activeSwatch = null; if (!sw) return; const start = +sw.dataset.start, end = +sw.dataset.end; if (!parseColor(textarea.value.slice(start, end))) return; const picked = parseColor(colorInput.value); if (!picked) return; const alpha = sw.dataset.alpha ? parseFloat(sw.dataset.alpha) : undefined; const replacement = formatColor(sw.dataset.format || 'hex', picked.r, picked.g, picked.b, alpha); textarea.focus(); textarea.setSelectionRange(start, end); let ok = false; try { ok = d.execCommand('insertText', false, replacement); } catch { } if (!ok) { textarea.value = textarea.value.slice(0, start) + replacement + textarea.value.slice(end); textarea.setSelectionRange(start + replacement.length, start + replacement.length); } Settings.renderCustomCSSHighlight(textarea, highlight); renderSwatches(); $.event('change', null, textarea); }); let savedFlashTimer = 0; const flashSaved = () => { const badge = $('.custom-css-saved', editor); if (!badge) return; badge.dataset.show = 'true'; clearTimeout(savedFlashTimer); savedFlashTimer = window.setTimeout(() => { badge.dataset.show = 'false'; }, 900); }; let curButton = $('#custom-css-detach', section); let curDetails = editor.closest('.styling-custom-css'); let panel = null; let detachNote = null; let panelResizeObserver = null; let panelSaveTimer = 0; const placeDetachNote = (before) => { detachNote = $.el('div', { className: 'custom-css-detached-note', innerHTML: 'Custom CSS is detached. ', }); before.parentNode.insertBefore(detachNote, before); $.on($('.custom-css-reattach', detachNote), 'click', closeDetach); }; const writePanelRect = () => { if (!panel) return; const r = panel.getBoundingClientRect(); $.set('settings.customCSSPanel', { left: Math.round(r.left), top: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height), }); }; const savePanelRect = () => { clearTimeout(panelSaveTimer); panelSaveTimer = window.setTimeout(writePanelRect, 250); }; const refreshPanelTheme = () => { if (!panel) return; const ecs = window.getComputedStyle(editor); for (const v of ['--custom-css-bg', '--custom-css-text', '--custom-css-border']) { panel.style.setProperty(v, ecs.getPropertyValue(v)); } }; const centerPanelDefault = () => { if (!panel) return; const maxW = window.innerWidth, maxH = window.innerHeight; const w = Math.min(1100, Math.round(maxW * 0.96)); const h = Math.min(820, Math.round(maxH * 0.92)); panel.style.width = `${w}px`; panel.style.height = `${h}px`; panel.style.left = `${Math.round((maxW - w) / 2)}px`; panel.style.top = `${Math.round((maxH - h) / 2)}px`; syncScroll(); }; const resetPanel = () => { centerPanelDefault(); writePanelRect(); }; const onBarMousedown = (e) => { if (e.button !== 0 || !panel) return; if (e.target.closest('button')) return; e.preventDefault(); const rect = panel.getBoundingClientRect(); const dx = e.clientX - rect.left; const dy = e.clientY - rect.top; const onMove = (me) => { if (!panel) return; const left = Math.max(0, Math.min(me.clientX - dx, window.innerWidth - panel.offsetWidth)); const top = Math.max(0, Math.min(me.clientY - dy, window.innerHeight - panel.offsetHeight)); panel.style.left = `${left}px`; panel.style.top = `${top}px`; }; const onUp = () => { $.off(d, 'mousemove', onMove); $.off(d, 'mouseup', onUp); writePanelRect(); }; $.on(d, 'mousemove', onMove); $.on(d, 'mouseup', onUp); }; const closeDetach = () => { if (!panel) return; panelResizeObserver?.disconnect(); panelResizeObserver = null; clearTimeout(panelSaveTimer); writePanelRect(); if (detachNote?.parentNode && curDetails) { detachNote.parentNode.insertBefore(curDetails, detachNote); detachNote.remove(); } detachNote = null; if (curDetails) delete curDetails.dataset.detached; $.rm(panel); panel = null; if (curButton) curButton.dataset.open = 'false'; delete Settings.detached['settings.customCSSPanel']; const sec = $('section', Settings.dialog); if (sec) Settings.applyAccordionMode(sec); syncScroll(); }; const adoptDetach = (freshDetails, freshButton) => { placeDetachNote(freshDetails); $.rm(freshDetails); curButton = freshButton; curButton.dataset.open = 'true'; $.on(curButton, 'click', toggleDetach); }; const openDetach = () => { if (panel || !curDetails) return; placeDetachNote(curDetails); panel = $.el('div', { className: 'custom-css-panel section-styling', innerHTML: '
    ' + 'Custom CSS' + '' + '' + '' + '' + '
    ' + '
    ', }); const bar = $('.custom-css-panel-bar', panel); const body = $('.custom-css-panel-body', panel); curDetails.removeAttribute('name'); curDetails.open = true; curDetails.dataset.detached = 'true'; $.add(body, curDetails); $.on($('.custom-css-panel-close', panel), 'click', closeDetach); $.on($('.custom-css-panel-reset', panel), 'click', resetPanel); $.on(bar, 'mousedown', onBarMousedown); $.on(panel, 'mousedown', () => Settings.raiseToFront(panel)); $.add(Settings.dialog || section, panel); Settings.raiseToFront(panel); Settings.detached['settings.customCSSPanel'] = { adopt: adoptDetach }; refreshPanelTheme(); $.get({ 'settings.customCSSPanel': null }, (prefs) => { if (!panel) return; const saved = prefs['settings.customCSSPanel']; if (saved && saved.width) { const maxW = window.innerWidth, maxH = window.innerHeight; const w = Math.min(saved.width, maxW - 20); const h = Math.min(saved.height, maxH - 20); panel.style.width = `${w}px`; panel.style.height = `${h}px`; panel.style.left = `${Math.max(0, Math.min(saved.left, maxW - w))}px`; panel.style.top = `${Math.max(0, Math.min(saved.top, maxH - h))}px`; syncScroll(); } else { centerPanelDefault(); } panelResizeObserver = new ResizeObserver(savePanelRect); panelResizeObserver.observe(panel); }); if (curButton) curButton.dataset.open = 'true'; textarea.focus(); }; function toggleDetach(e) { e.preventDefault(); e.stopPropagation(); panel ? closeDetach() : openDetach(); } if (curButton) $.on(curButton, 'click', toggleDetach); const updateTheme = (save = false) => { const choice = themeSelect.value || 'xt-system'; editor.dataset.theme = Settings.resolveCustomCSSEditorTheme(choice); Settings.applyCustomCSSEditorSystemColors(editor); refreshPanelTheme(); if (save) $.set('settings.customCSSEditorTheme', choice); }; const updateBracketHighlight = (enabled, save = false) => { bracketToggle.checked = enabled; highlight.dataset.bracketHighlight = enabled ? 'true' : 'false'; if (save) $.set('settings.customCSSEditorBracketHighlight', enabled); Settings.renderCustomCSSHighlight(textarea, highlight); syncScroll(); }; const updateAutocomplete = (enabled, save = false) => { autocompleteToggle.checked = enabled; editor.dataset.autocomplete = enabled ? 'true' : 'false'; if (save) $.set('settings.customCSSEditorAutocomplete', enabled); }; let bracketRaf = 0; let lastBracketKey = ''; const bracketKey = () => { if (!bracketToggle.checked || textarea.selectionStart !== textarea.selectionEnd) return ''; const marks = Settings.customCSSBracketHighlights(textarea.value, textarea.selectionStart, textarea.selectionEnd); return marks ? Object.keys(marks).sort((a, b) => +a - +b).map(k => `${k}:${marks[k]}`).join(',') : ''; }; const renderForCaret = () => { if (!bracketToggle.checked || bracketRaf) return; bracketRaf = requestAnimationFrame(() => { bracketRaf = 0; if (!bracketToggle.checked) return; const key = bracketKey(); if (key === lastBracketKey) return; lastBracketKey = key; Settings.renderCustomCSSHighlight(textarea, highlight); }); }; $.on(textarea, 'input', () => { Settings.renderCustomCSSHighlight(textarea, highlight); renderSwatches(); }); $.on(textarea, 'focus', renderSwatches); $.on(textarea, 'keyup mouseup select focus', renderForCaret); $.on(textarea, 'scroll', () => { syncScroll(); hideSwatchFan(); }); Settings.bindCustomCSSEditorKeys(textarea, highlight); $.on(textarea, 'change', () => { Settings.renderCustomCSSHighlight(textarea, highlight); renderSwatches(); if (Conf['Custom CSS']) CustomCSS.update(); flashSaved(); }); $.on(themeSelect, 'change', () => updateTheme(true)); $.on(bracketToggle, 'change', () => updateBracketHighlight(bracketToggle.checked, true)); $.on(autocompleteToggle, 'change', () => updateAutocomplete(autocompleteToggle.checked, true)); if (formatButton) $.on(formatButton, 'click', () => Settings.applyCustomCSSFormat(textarea, highlight)); const shortcutsToggle = $('#custom-css-shortcuts-toggle', section); const shortcuts = $('#custom-css-shortcuts', section); if (shortcutsToggle && shortcuts) $.on(shortcutsToggle, 'click', () => { const show = shortcuts.hidden; shortcuts.hidden = !show; shortcutsToggle.setAttribute('aria-expanded', String(show)); }); const formatOpts = $('.ccss-format-opts', section); const formatGear = $('#custom-css-format-gear', section); const formatPopover = formatOpts ? $('.ccss-format-popover', formatOpts) : null; const indentSelect = $('#custom-css-format-indent', section); const blankLinesToggle = $('#custom-css-format-blanklines', section); const selectorPerLineToggle = $('#custom-css-format-selectorperline', section); if (formatOpts && formatGear && formatPopover && indentSelect && blankLinesToggle && selectorPerLineToggle) { const indentValue = (v) => (v === 'tab' ? '\t' : v === '4' ? ' ' : ' '); const syncFormatOptions = (save = false) => { Settings.customCSSFormatOptions = { indent: indentValue(indentSelect.value), blankLines: blankLinesToggle.checked, selectorPerLine: selectorPerLineToggle.checked, }; if (save) $.set({ 'settings.customCSSFormatIndent': indentSelect.value, 'settings.customCSSFormatBlankLines': blankLinesToggle.checked, 'settings.customCSSFormatSelectorPerLine': selectorPerLineToggle.checked, }); }; let formatOutsideHandler = null; const closePopover = () => { formatOpts.dataset.open = 'false'; formatPopover.hidden = true; formatGear.setAttribute('aria-expanded', 'false'); if (formatOutsideHandler) { d.removeEventListener('mousedown', formatOutsideHandler, true); formatOutsideHandler = null; } }; const openPopover = () => { formatOpts.dataset.open = 'true'; formatPopover.hidden = false; formatGear.setAttribute('aria-expanded', 'true'); formatOutsideHandler = (e) => { const target = e.target; if (target && !formatOpts.contains(target)) closePopover(); }; d.addEventListener('mousedown', formatOutsideHandler, true); }; $.on(formatGear, 'click', () => { (formatPopover.hidden ? openPopover : closePopover)(); }); $.on(indentSelect, 'change', () => syncFormatOptions(true)); $.on(blankLinesToggle, 'change', () => syncFormatOptions(true)); $.on(selectorPerLineToggle, 'change', () => syncFormatOptions(true)); $.get({ 'settings.customCSSFormatIndent': '2', 'settings.customCSSFormatBlankLines': true, 'settings.customCSSFormatSelectorPerLine': true, }, (prefs) => { const indent = prefs['settings.customCSSFormatIndent']; indentSelect.value = ['2', '4', 'tab'].includes(indent) ? indent : '2'; blankLinesToggle.checked = prefs['settings.customCSSFormatBlankLines'] !== false; selectorPerLineToggle.checked = prefs['settings.customCSSFormatSelectorPerLine'] !== false; syncFormatOptions(false); }); } Settings.customCSSEditorThemeObserver?.disconnect(); Settings.customCSSEditorThemeObserver = new MutationObserver(() => { if (themeSelect.value === 'xt-system') updateTheme(false); }); Settings.customCSSEditorThemeObserver.observe(doc, { attributes: true, attributeFilter: ['class'], }); $.get({ 'settings.customCSSEditorTheme': 'xt-system', 'settings.customCSSEditorBracketHighlight': false, 'settings.customCSSEditorAutocomplete': true, }, (prefs) => { const theme = prefs['settings.customCSSEditorTheme']; const bracketHighlight = prefs['settings.customCSSEditorBracketHighlight'] !== false; const autocomplete = prefs['settings.customCSSEditorAutocomplete'] !== false; themeSelect.value = ['xt-system', 'xt-light', 'xt-dark', 'xt-solarized'].includes(theme) ? theme : 'xt-system'; updateTheme(false); updateBracketHighlight(bracketHighlight, false); updateAutocomplete(autocomplete, false); renderSwatches(); syncScroll(); }); }, bindCustomCSSEditorKeys(textarea, highlight) { const PAIRS = { '{': '}', '(': ')', '[': ']', '"': '"', "'": "'" }; const CLOSERS = new Set(['}', ')', ']']); const isQuote = (ch) => ch === '"' || ch === "'"; const refresh = () => { Settings.renderCustomCSSHighlight(textarea, highlight); highlight.scrollTop = textarea.scrollTop; highlight.scrollLeft = textarea.scrollLeft; }; const insert = (text, caretBack = 0) => { const start = textarea.selectionStart; let ok = false; try { ok = d.execCommand('insertText', false, text); } catch { } if (!ok) { const end = textarea.selectionEnd; textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(end); } const caret = start + text.length - caretBack; textarea.selectionStart = textarea.selectionEnd = caret; }; const wrap = (open, close) => { const start = textarea.selectionStart; const inner = textarea.value.slice(start, textarea.selectionEnd); let ok = false; try { ok = d.execCommand('insertText', false, open + inner + close); } catch { } if (!ok) { const end = textarea.selectionEnd; textarea.value = textarea.value.slice(0, start) + open + inner + close + textarea.value.slice(end); } textarea.selectionStart = start + open.length; textarea.selectionEnd = start + open.length + inner.length; }; const lineIndent = (val, pos) => { const lineStart = val.lastIndexOf('\n', pos - 1) + 1; return (val.slice(lineStart, pos).match(/^[ \t]*/) || [''])[0]; }; const COMMENTED = /^(\s*)\/\*\s?([\s\S]*?)\s?\*\/(\s*)$/; const ZW = '\u200B'; const shield = (s) => s.replace(/\*\//g, `*${ZW}/`).replace(/\/\*/g, `/${ZW}*`); const unshield = (s) => s.replace(/\*\u200B\//g, '*/').replace(/\/\u200B\*/g, '/*'); const toggleComment = () => { const val = textarea.value; const selStart = textarea.selectionStart; let selEnd = textarea.selectionEnd; if (selEnd > selStart && val[selEnd - 1] === '\n') selEnd--; const blockStart = val.lastIndexOf('\n', selStart - 1) + 1; let blockEnd = val.indexOf('\n', selEnd); if (blockEnd === -1) blockEnd = val.length; const block = val.slice(blockStart, blockEnd); const lead = (block.match(/^\s*/) || [''])[0]; const trail = (block.match(/\s*$/) || [''])[0]; const core = block.slice(lead.length, block.length - trail.length); if (!core.length) return; const lines = block.split('\n'); const nonBlank = lines.filter(l => l.trim().length); const wrapped = core.match(/^\/\*\s?([\s\S]*?)\s?\*\/$/); let next; if (nonBlank.length > 0 && nonBlank.every(l => COMMENTED.test(l))) { next = lines.map(l => { if (!l.trim().length) return l; const m = l.match(COMMENTED); return m ? m[1] + unshield(m[2]) + m[3] : l; }).join('\n'); } else if (wrapped) { next = lead + unshield(wrapped[1]) + trail; } else { next = lead + `/* ${shield(core)} */` + trail; } textarea.selectionStart = blockStart; textarea.selectionEnd = blockEnd; let ok = false; try { ok = d.execCommand('insertText', false, next); } catch { } if (!ok) textarea.value = val.slice(0, blockStart) + next + val.slice(blockEnd); textarea.selectionStart = blockStart; textarea.selectionEnd = blockStart + next.length; refresh(); }; const editor = textarea.closest('.custom-css-editor'); const AC_MAX = 12; let acItems = []; let acActive = 0; let acTokenStart = 0; let acDropdown = null; let acSuppress = false; let pageClasses = null; let charWidth = 0; const collectClasses = () => { if (pageClasses) return pageClasses; const set = new Set(); for (const el of d.querySelectorAll('[class]')) { if (Settings.dialog?.contains(el)) continue; for (const c of el.classList) { if (c.length <= 40 && /^[-_a-zA-Z][-\w]*$/.test(c)) set.add(c); } } pageClasses = Array.from(set).sort((a, b) => a.localeCompare(b)); return pageClasses; }; const measureCharWidth = () => { if (charWidth) return charWidth; const cs = window.getComputedStyle(textarea); const probe = $.el('span', { textContent: 'x'.repeat(80) }); probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;top:0;left:0'; probe.style.fontFamily = cs.fontFamily; probe.style.fontSize = cs.fontSize; probe.style.fontWeight = cs.fontWeight; probe.style.fontStyle = cs.fontStyle; probe.style.letterSpacing = cs.letterSpacing; $.add(d.body, probe); charWidth = probe.getBoundingClientRect().width / 80; $.rm(probe); return charWidth || 7; }; const acToken = () => { const pos = textarea.selectionStart; if (pos !== textarea.selectionEnd) return null; const lineStart = textarea.value.lastIndexOf('\n', pos - 1) + 1; const left = textarea.value.slice(lineStart, pos); const m = left.match(/\.([-\w]*)$/); if (!m) return null; if (m.index > 0 && /\d/.test(left[m.index - 1])) return null; return { partial: m[1], start: pos - m[1].length }; }; const acFilter = (partial) => { const all = collectClasses(); if (!partial) return all.slice(0, AC_MAX); const p = partial.toLowerCase(); const starts = [], contains = []; for (const c of all) { const lc = c.toLowerCase(); if (lc.startsWith(p)) starts.push(c); else if (lc.includes(p)) contains.push(c); if (starts.length >= AC_MAX) break; } return starts.concat(contains).slice(0, AC_MAX); }; const acIsOpen = () => !!acDropdown && !acDropdown.hidden; const closeAC = () => { if (acDropdown) acDropdown.hidden = true; acItems = []; acActive = 0; }; const positionAC = () => { if (!acDropdown) return; const cs = window.getComputedStyle(textarea); const padL = parseFloat(cs.paddingLeft) || 0; const padT = parseFloat(cs.paddingTop) || 0; const lineH = parseFloat(cs.lineHeight) || (parseFloat(cs.fontSize) * 1.42) || 17; const pos = textarea.selectionStart; const upto = textarea.value.slice(0, pos); const row = (upto.match(/\n/g) || []).length; const col = pos - (upto.lastIndexOf('\n') + 1); const taRect = textarea.getBoundingClientRect(); let x = taRect.left + padL + col * measureCharWidth() - textarea.scrollLeft; const y = taRect.top + padT + row * lineH - textarea.scrollTop; x = Math.max(taRect.left, Math.min(x, taRect.right - acDropdown.offsetWidth - 4)); const below = y + lineH; const h = acDropdown.offsetHeight; const top = (below + h > taRect.bottom && y - h > taRect.top) ? y - h : below; acDropdown.style.left = `${Math.round(x)}px`; acDropdown.style.top = `${Math.round(top)}px`; }; const acceptAC = () => { if (!acIsOpen() || !acItems.length) { closeAC(); return; } const choice = acItems[acActive]; const pos = textarea.selectionStart; acSuppress = true; textarea.selectionStart = acTokenStart; textarea.selectionEnd = pos; insert(choice); closeAC(); refresh(); }; const renderAC = () => { if (!acDropdown) { acDropdown = $.el('div', { className: 'css-autocomplete' }); acDropdown.hidden = true; $.add(Settings.dialog || d.body, acDropdown); $.on(acDropdown, 'mousedown', (ev) => ev.preventDefault()); } if (editor) { const ecs = window.getComputedStyle(editor); acDropdown.style.setProperty('--ac-bg', ecs.getPropertyValue('--custom-css-bg') || '#fff'); acDropdown.style.setProperty('--ac-fg', ecs.getPropertyValue('--custom-css-text') || '#222'); acDropdown.style.setProperty('--ac-border', ecs.getPropertyValue('--custom-css-border') || 'rgba(128,128,128,.4)'); } acDropdown.textContent = ''; acItems.forEach((name, i) => { const item = $.el('div', { className: 'css-ac-item', textContent: name }); if (i === acActive) item.classList.add('active'); $.on(item, 'click', () => { acActive = i; acceptAC(); }); $.add(acDropdown, item); }); acDropdown.hidden = false; positionAC(); }; const updateAC = () => { if (acSuppress) { acSuppress = false; closeAC(); return; } if (editor?.dataset.autocomplete === 'false') { closeAC(); return; } const tok = acToken(); if (!tok) { closeAC(); return; } const matches = acFilter(tok.partial); if (!matches.length) { closeAC(); return; } acItems = matches; acTokenStart = tok.start; if (acActive >= matches.length) acActive = 0; renderAC(); }; const moveAC = (dir) => { if (!acItems.length) return; acActive = (acActive + dir + acItems.length) % acItems.length; renderAC(); acDropdown?.querySelector('.css-ac-item.active') ?.scrollIntoView({ block: 'nearest' }); }; $.on(textarea, 'input', updateAC); $.on(textarea, 'blur', () => setTimeout(closeAC, 120)); $.on(textarea, 'scroll', () => { if (acIsOpen()) positionAC(); }); $.on(textarea, 'click', () => { if (acIsOpen()) updateAC(); }); $.on(textarea, 'keydown', (e) => { if (acIsOpen()) { if (e.key === 'ArrowDown') { e.preventDefault(); moveAC(1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); moveAC(-1); return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); acceptAC(); return; } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeAC(); return; } } if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === '/') { e.preventDefault(); toggleComment(); return; } if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === 's' || e.key === 'S')) { e.preventDefault(); e.stopPropagation(); $.event('change', null, textarea); return; } if (e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyF') { e.preventDefault(); e.stopPropagation(); closeAC(); Settings.applyCustomCSSFormat(textarea, highlight); return; } if (e.ctrlKey || e.metaKey || e.altKey) return; const val = textarea.value; const start = textarea.selectionStart; const end = textarea.selectionEnd; const hasSel = start !== end; const before = val[start - 1]; const after = val[end]; const indentUnit = Settings.customCSSFormatOptions.indent; const indentWidth = indentUnit === '\t' ? 1 : indentUnit.length; const dedentPattern = `(?:\\t| {1,${indentWidth}})`; if (e.key === 'Enter' && !e.shiftKey && !hasSel) { const indent = lineIndent(val, start); if (before === '{' && after === '}') { insert(`\n${indent}${indentUnit}\n${indent}`, indent.length + 1); } else { insert(`\n${indent}${before === '{' ? indentUnit : ''}`); } e.preventDefault(); refresh(); return; } if (e.key === 'Tab') { e.preventDefault(); const blockStart = val.lastIndexOf('\n', start - 1) + 1; const multiline = val.slice(start, end).includes('\n'); if (multiline) { const block = val.slice(blockStart, end); const next = e.shiftKey ? block.replace(new RegExp(`^${dedentPattern}`, 'gm'), '') : block.replace(/^/gm, indentUnit); textarea.selectionStart = blockStart; textarea.selectionEnd = end; let ok = false; try { ok = d.execCommand('insertText', false, next); } catch { } if (!ok) textarea.value = val.slice(0, blockStart) + next + val.slice(end); textarea.selectionStart = blockStart; textarea.selectionEnd = blockStart + next.length; } else if (e.shiftKey) { const lead = (val.slice(blockStart).match(new RegExp(`^${dedentPattern}`)) || [''])[0]; if (lead) { textarea.value = val.slice(0, blockStart) + val.slice(blockStart + lead.length); const caret = Math.max(blockStart, start - lead.length); textarea.selectionStart = textarea.selectionEnd = caret; } } else { insert(indentUnit); } refresh(); return; } if (e.key === 'Backspace' && !hasSel && before && PAIRS[before] === after) { textarea.selectionStart = start - 1; textarea.selectionEnd = start + 1; let ok = false; try { ok = d.execCommand('delete', false); } catch { } if (!ok) { textarea.value = val.slice(0, start - 1) + val.slice(start + 1); textarea.selectionStart = textarea.selectionEnd = start - 1; } e.preventDefault(); refresh(); return; } if (e.key.length !== 1) return; if (!hasSel && e.key === after && (CLOSERS.has(e.key) || isQuote(e.key))) { textarea.selectionStart = textarea.selectionEnd = end + 1; e.preventDefault(); refresh(); return; } if (PAIRS[e.key]) { if (hasSel) { wrap(e.key, PAIRS[e.key]); e.preventDefault(); refresh(); return; } const wordChar = before && /[\w'"]/.test(before); if (isQuote(e.key) && wordChar) return; insert(e.key + PAIRS[e.key], 1); e.preventDefault(); refresh(); } }); }, bindPlainEditorKeys(textarea, token = '#') { const esc = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const COMMENTED = new RegExp(`^(\\s*)${esc} ?`); const replace = (start, end, next) => { textarea.selectionStart = start; textarea.selectionEnd = end; let ok = false; try { ok = d.execCommand('insertText', false, next); } catch { } if (!ok) textarea.value = textarea.value.slice(0, start) + next + textarea.value.slice(end); textarea.selectionStart = start; textarea.selectionEnd = start + next.length; }; const toggleComment = () => { const val = textarea.value; const selStart = textarea.selectionStart; let selEnd = textarea.selectionEnd; if (selEnd > selStart && val[selEnd - 1] === '\n') selEnd--; const blockStart = val.lastIndexOf('\n', selStart - 1) + 1; let blockEnd = val.indexOf('\n', selEnd); if (blockEnd === -1) blockEnd = val.length; const lines = val.slice(blockStart, blockEnd).split('\n'); const nonBlank = lines.filter(l => l.trim().length); if (!nonBlank.length) return; const allCommented = nonBlank.every(l => COMMENTED.test(l)); const next = lines.map(l => { if (!l.trim().length) return l; if (allCommented) return l.replace(COMMENTED, '$1'); const indent = (l.match(/^\s*/) || [''])[0]; return `${indent}${token} ${l.slice(indent.length)}`; }).join('\n'); replace(blockStart, blockEnd, next); }; $.on(textarea, 'keydown', (e) => { if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === '/') { e.preventDefault(); toggleComment(); return; } if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.key !== 'Tab') return; e.preventDefault(); const val = textarea.value; const start = textarea.selectionStart; const end = textarea.selectionEnd; const blockStart = val.lastIndexOf('\n', start - 1) + 1; const multiline = val.slice(start, end).includes('\n'); if (multiline) { const block = val.slice(blockStart, end); const next = e.shiftKey ? block.replace(/^[ \t]{1,2}/gm, '') : block.replace(/^/gm, ' '); replace(blockStart, end, next); } else if (e.shiftKey) { const lead = (val.slice(blockStart).match(/^[ \t]{1,2}/) || [''])[0]; if (lead) { textarea.value = val.slice(0, blockStart) + val.slice(blockStart + lead.length); const caret = Math.max(blockStart, start - lead.length); textarea.selectionStart = textarea.selectionEnd = caret; } } else { let ok = false; try { ok = d.execCommand('insertText', false, ' '); } catch { } if (!ok) { textarea.value = val.slice(0, start) + ' ' + val.slice(end); textarea.selectionStart = textarea.selectionEnd = start + 2; } } }); }, detachZ: 10, raiseToFront(el) { if (el) el.style.zIndex = String(++Settings.detachZ); }, detached: Object.create(null), makeDetachable(node, button, opts) { if (!node || !button) return; const live = Settings.detached[opts.storageKey]; if (live) { live.adopt(node, button); return; } const host = Settings.dialog || d.body; let curNode = node; let curButton = button; let panel = null; let note = null; let ro = null; let saveTimer = 0; const placeNote = (before) => { note = $.el('div', { className: 'xt-detach-note', innerHTML: `${opts.title} is detached. `, }); before.parentNode.insertBefore(note, before); $.on($('.xt-detach-reattach', note), 'click', close); }; const writeRect = () => { if (!panel) return; const r = panel.getBoundingClientRect(); $.set(opts.storageKey, { left: Math.round(r.left), top: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height), }); }; const saveRect = () => { clearTimeout(saveTimer); saveTimer = window.setTimeout(writeRect, 250); }; const centerDefault = () => { if (!panel) return; const maxW = window.innerWidth, maxH = window.innerHeight; const w = Math.min(1100, Math.round(maxW * 0.96)); const h = Math.min(820, Math.round(maxH * 0.92)); panel.style.width = `${w}px`; panel.style.height = `${h}px`; panel.style.left = `${Math.round((maxW - w) / 2)}px`; panel.style.top = `${Math.round((maxH - h) / 2)}px`; }; const resetPanel = () => { centerDefault(); writeRect(); }; const onBarMousedown = (e) => { if (e.button !== 0 || !panel) return; if (e.target.closest('button')) return; e.preventDefault(); const rect = panel.getBoundingClientRect(); const dx = e.clientX - rect.left; const dy = e.clientY - rect.top; const onMove = (me) => { if (!panel) return; const left = Math.max(0, Math.min(me.clientX - dx, window.innerWidth - panel.offsetWidth)); const top = Math.max(0, Math.min(me.clientY - dy, window.innerHeight - panel.offsetHeight)); panel.style.left = `${left}px`; panel.style.top = `${top}px`; }; const onUp = () => { $.off(d, 'mousemove', onMove); $.off(d, 'mouseup', onUp); writeRect(); }; $.on(d, 'mousemove', onMove); $.on(d, 'mouseup', onUp); }; const close = () => { if (!panel) return; ro?.disconnect(); ro = null; clearTimeout(saveTimer); writeRect(); if (note?.parentNode) { note.parentNode.insertBefore(curNode, note); note.remove(); } note = null; delete curNode.dataset.detached; $.rm(panel); panel = null; curButton.dataset.open = 'false'; delete Settings.detached[opts.storageKey]; const sec = $('section', Settings.dialog); if (sec) Settings.applyAccordionMode(sec); }; const adopt = (freshNode, freshButton) => { placeNote(freshNode); $.rm(freshNode); curButton = freshButton; curButton.dataset.open = 'true'; $.on(curButton, 'click', toggle); }; const open = () => { if (panel) return; placeNote(curNode); panel = $.el('div', { className: 'xt-detach-panel', innerHTML: '
    ' + `${opts.title}` + '' + '' + '' + '' + '
    ' + '
    ', }); const bar = $('.xt-detach-panel-bar', panel); const body = $('.xt-detach-panel-body', panel); if (curNode.tagName === 'DETAILS') { curNode.removeAttribute('name'); curNode.open = true; } curNode.dataset.detached = 'true'; $.add(body, curNode); $.on($('.xt-detach-panel-close', panel), 'click', close); $.on($('.xt-detach-panel-reset', panel), 'click', resetPanel); $.on(bar, 'mousedown', onBarMousedown); $.on(panel, 'mousedown', () => Settings.raiseToFront(panel)); $.add(host, panel); Settings.raiseToFront(panel); Settings.detached[opts.storageKey] = { adopt }; $.get({ [opts.storageKey]: null }, ((prefs) => { if (!panel) return; const saved = prefs[opts.storageKey]; if (saved && saved.width) { const maxW = window.innerWidth, maxH = window.innerHeight; const w = Math.min(saved.width, maxW - 20); const h = Math.min(saved.height, maxH - 20); panel.style.width = `${w}px`; panel.style.height = `${h}px`; panel.style.left = `${Math.max(0, Math.min(saved.left, maxW - w))}px`; panel.style.top = `${Math.max(0, Math.min(saved.top, maxH - h))}px`; } else { centerDefault(); } ro = new ResizeObserver(saveRect); ro.observe(panel); })); curButton.dataset.open = 'true'; const ta = curNode.tagName === 'TEXTAREA' ? curNode : $('textarea', curNode); ta?.focus(); }; function toggle(e) { e.preventDefault(); e.stopPropagation(); panel ? close() : open(); } $.on(button, 'click', toggle); }, applyCustomCSSFormat(textarea, highlight) { const formatted = Settings.formatCustomCSS(textarea.value); if (formatted === textarea.value) return false; textarea.focus(); textarea.selectionStart = 0; textarea.selectionEnd = textarea.value.length; let ok = false; try { ok = d.execCommand('insertText', false, formatted); } catch { } if (!ok) textarea.value = formatted; textarea.selectionStart = textarea.selectionEnd = 0; textarea.scrollTop = textarea.scrollLeft = 0; $.event('change', null, textarea); Settings.renderCustomCSSHighlight(textarea, highlight); highlight.scrollTop = textarea.scrollTop; highlight.scrollLeft = textarea.scrollLeft; return true; }, formatCustomCSS(css, opts = Settings.customCSSFormatOptions) { const INDENT = opts.indent ?? ' '; const blankLines = opts.blankLines !== false; const selectorPerLine = opts.selectorPerLine !== false; const stash = []; const work = css .replace(/\/\*[\s\S]*?\*\//g, m => `${stash.push(m) - 1}`) .replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, m => `${stash.push(m) - 1}`); const PURE_PLACEHOLDER = /^(?:\d+\s*)+$/; let out = ''; let depth = 0; let buf = ''; const pad = () => INDENT.repeat(depth); const peelComments = (s) => { let m; while ((m = s.match(/^\s*(\d+)\s*/))) { out += pad() + m[1] + '\n'; s = s.slice(m[0].length); } return s; }; const splitTopLevel = (s, sep) => { const parts = []; let nest = 0, last = 0; for (let i = 0; i < s.length; i++) { const c = s[i]; if (c === '(' || c === '[') nest++; else if (c === ')' || c === ']') nest = Math.max(0, nest - 1); else if (c === sep && nest === 0) { parts.push(s.slice(last, i)); last = i + 1; } } parts.push(s.slice(last)); return parts; }; const flushDecl = () => { let t = peelComments(buf.replace(/\s+/g, ' ').trim()).trim(); buf = ''; if (!t) return; if (PURE_PLACEHOLDER.test(t)) { out += pad() + t + '\n'; return; } const ci = t.indexOf(':'); if (ci > 0) { const prop = t.slice(0, ci).trim(); if (/^(?:--)?[-\w]+$/.test(prop)) t = `${prop}: ${t.slice(ci + 1).trim()}`; } t = t.replace(/\s*!important/gi, ' !important'); out += pad() + t + ';\n'; }; for (let i = 0; i < work.length; i++) { const ch = work[i]; if (ch === '{') { if (blankLines && out && !out.endsWith('\n\n') && !out.endsWith('{\n')) out += '\n'; const sel = peelComments(buf.replace(/\s+/g, ' ').trim()).trim(); buf = ''; const parts = splitTopLevel(sel, ',').map(p => p.trim()); if (parts.length > 1 && selectorPerLine) { parts.forEach((p, n) => { out += pad() + p + (n < parts.length - 1 ? ',\n' : ' {\n'); }); } else { const joined = parts.join(', '); out += pad() + (joined ? joined + ' ' : '') + '{\n'; } depth++; } else if (ch === '}') { flushDecl(); depth = Math.max(0, depth - 1); out += pad() + '}\n'; } else if (ch === ';') { flushDecl(); } else { buf += ch; } } flushDecl(); out = out.replace(/(\d+)/g, (_, n) => stash[+n]); return out.replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\s+$/, '') + '\n'; }, resolveCustomCSSEditorTheme(theme) { if (theme !== 'xt-system') return theme || 'xt-light'; const classList = doc.classList; if (classList.contains('tomorrow')) return 'xt-system-tomorrow'; if (classList.contains('spooky')) return 'xt-system-spooky'; if (classList.contains('yotsuba-b')) return 'xt-system-yotsuba-b'; if (classList.contains('burichan')) return 'xt-system-burichan'; if (classList.contains('yotsuba')) return 'xt-system-yotsuba'; if (classList.contains('futaba')) return 'xt-system-futaba'; if (classList.contains('photon')) return 'xt-system-photon'; return 'xt-system-live'; }, applyCustomCSSEditorSystemColors(editor) { const clear = () => { for (const v of ['--custom-css-bg', '--custom-css-text', '--custom-css-caret', '--custom-css-border']) { editor.style.removeProperty(v); } editor.style.colorScheme = ''; }; if (editor.dataset.theme !== 'xt-system-live') { clear(); return; } const sample = (el) => { if (!el) return null; const cs = window.getComputedStyle(el); const m = cs.backgroundColor.match(/[\d.]+/g); const opaque = m && m.length >= 3 && !(m[3] !== undefined && parseFloat(m[3]) === 0); return opaque ? { bg: cs.backgroundColor, fg: cs.color } : null; }; const colors = sample(d.body) || sample(d.documentElement); if (!colors) { clear(); return; } editor.style.setProperty('--custom-css-bg', colors.bg); editor.style.setProperty('--custom-css-text', colors.fg); editor.style.setProperty('--custom-css-caret', colors.fg); const fg = colors.fg.match(/[\d.]+/g); if (fg && fg.length >= 3) { editor.style.setProperty('--custom-css-border', `rgba(${fg[0]}, ${fg[1]}, ${fg[2]}, .35)`); editor.style.colorScheme = $.luma([+fg[0], +fg[1], +fg[2]]) > 128 ? 'dark' : 'light'; } }, refreshCustomCSSEditor(section) { const textarea = $('.custom-css-textarea', section); const highlight = $('.custom-css-highlight', section); if (!textarea || !highlight) return; Settings.renderCustomCSSHighlight(textarea, highlight); highlight.scrollTop = textarea.scrollTop; highlight.scrollLeft = textarea.scrollLeft; }, renderCustomCSSHighlight(textarea, highlight) { const text = textarea.value; const source = text ? `${text}\n` : '\n'; const bracketHighlights = highlight.dataset.bracketHighlight === 'false' ? null : Settings.customCSSBracketHighlights(text, textarea.selectionStart, textarea.selectionEnd); highlight.innerHTML = Settings.highlightCSSSource(source, bracketHighlights); }, customCSSBracketHighlights(source, selectionStart, selectionEnd) { if (selectionStart !== selectionEnd) return null; const pairs = { '{': '}', '(': ')', '[': ']' }; const reversePairs = { '}': '{', ')': '(', ']': '[' }; const isBracket = (ch) => !!(pairs[ch] || reversePairs[ch]); let anchor = -1; if (isBracket(source[selectionStart - 1])) { anchor = selectionStart - 1; } else if (isBracket(source[selectionStart])) { anchor = selectionStart; } if (anchor < 0) return null; const ignored = Settings.customCSSIgnoredCharMask(source); if (ignored[anchor]) return null; const ch = source[anchor]; const isOpen = !!pairs[ch]; const open = isOpen ? ch : reversePairs[ch]; const close = isOpen ? pairs[ch] : ch; let depth = 0; if (isOpen) { for (let i = anchor; i < source.length; i++) { if (ignored[i]) continue; if (source[i] === open) depth++; if (source[i] === close) { depth--; if (depth === 0) { return { [anchor]: 'css-token-bracket-match', [i]: 'css-token-bracket-match', }; } } } } else { for (let i = anchor; i >= 0; i--) { if (ignored[i]) continue; if (source[i] === close) depth++; if (source[i] === open) { depth--; if (depth === 0) { return { [anchor]: 'css-token-bracket-match', [i]: 'css-token-bracket-match', }; } } } } return { [anchor]: 'css-token-bracket-mismatch' }; }, customCSSIgnoredCharMask(source) { const ignored = []; let i = 0; while (i < source.length) { if (source[i] === '/' && source[i + 1] === '*') { const start = i; i += 2; while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++; i = Math.min(source.length, i + 2); for (let j = start; j < i; j++) ignored[j] = true; continue; } if (source[i] === '"' || source[i] === "'") { const quote = source[i]; const start = i++; while (i < source.length) { if (source[i] === '\\') { i += 2; continue; } if (source[i++] === quote) break; } for (let j = start; j < i; j++) ignored[j] = true; continue; } i++; } return ignored; }, highlightCSSSource(source, bracketHighlights = null) { const wrapped = dict(); let wrappedCount = 0; const encodeTokenID = (index) => { let id = ''; let value = index; do { id = String.fromCharCode(97 + (value % 26)) + id; value = Math.floor(value / 26) - 1; } while (value >= 0); return id; }; const stash = (text, className) => { const id = encodeTokenID(wrappedCount++); wrapped[id] = `${E(text)}`; return `\uE000${id}\uE001`; }; let code = source; code = code.replace(/\/\*[\s\S]*?\*\//g, match => stash(match, 'css-token-comment')); code = code.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, match => stash(match, 'css-token-string')); code = E(code); code = code.replace(/(^|[\s{;])(@[a-z-]+)/gim, '$1$2'); code = code.replace(/(^|[;{]\s*)((?:--)?[-a-z_][\w-]*)(\s*:)/gim, '$1$2$3'); code = code.replace(/#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})\b/gi, '$&'); code = code.replace(/\b-?(?:\d+|\d*\.\d+)(?:px|em|rem|%|vh|vw|vmin|vmax|s|ms|deg|rad|fr|ch|ex)?\b/gi, '$&'); code = code.replace(/\b!important\b/gi, '$&'); code = code.replace(/\uE000([a-z]+)\uE001/g, (_, id) => wrapped[id] || ''); if (bracketHighlights) { code = Settings.applyCustomCSSVisibleCharSpans(code, bracketHighlights); } return code; }, applyCustomCSSVisibleCharSpans(html, spans) { let out = ''; let visibleIndex = 0; for (let i = 0; i < html.length; i++) { if (html[i] === '<') { const end = html.indexOf('>', i + 1); if (end === -1) { out += html.slice(i); break; } out += html.slice(i, end + 1); i = end; continue; } let chunk = html[i]; if (html[i] === '&') { const end = html.indexOf(';', i + 1); if (end !== -1) { chunk = html.slice(i, end + 1); i = end; } } const className = spans[visibleIndex]; out += className ? `${chunk}` : chunk; visibleIndex++; } return out; }, applyStylingVars() { const prevBgCache = Settings.styleBgCache; Settings.styleBgCache = prevBgCache || {}; try { let varsChanged = Settings.writeStyleVarsTo(doc, Settings.getBoardVariant(), true); Settings.syncLinkedMarkerColors(undefined, Settings.getBoardVariant()); if (Settings.dialog && Settings.stylingEditingVariant && Settings.stylingEditingVariant !== Settings.getBoardVariant()) { if (Settings.writeStyleVarsTo(Settings.dialog, Settings.stylingEditingVariant, false)) varsChanged = true; } else if (Settings.dialog) { if (Settings.clearStyleVarsOn(Settings.dialog)) varsChanged = true; } if (varsChanged) { Settings.resolvedStyleColorCache = null; Settings.refreshUnsetStylingColorInputs(); } Settings.refreshStylingPreviewFromDialog(); } finally { Settings.styleBgCache = prevBgCache; } }, siteThemeChangedRaf: 0, onSiteThemeChanged() { if (Settings.siteThemeChangedRaf) return; Settings.siteThemeChangedRaf = requestAnimationFrame(() => { Settings.siteThemeChangedRaf = 0; Settings.resolvedStyleColorCache = null; if (!Settings.dialog) return; Settings.refreshUnsetStylingColorInputs(); Settings.refreshStylingPreviewFromDialog(); }); }, applyStylingVarsRaf: 0, applyStylingVarsDeferred() { if (Settings.applyStylingVarsRaf) return; Settings.applyStylingVarsRaf = requestAnimationFrame(() => { Settings.applyStylingVarsRaf = 0; Settings.applyStylingVars(); }); }, STYLE_VAR_NAMES: [ '--xt-highlight-own', '--xt-highlight-you', '--xt-highlight-ghost', '--xt-highlight-own-opacity', '--xt-highlight-you-opacity', '--xt-highlight-ghost-opacity', '--xt-highlight-edge-width', '--xt-post-background', '--xt-catalog-border-width', '--xt-edge-width-own', '--xt-edge-width-you', '--xt-edge-width-ghost', '--xt-edge-style-own', '--xt-edge-style-you', '--xt-edge-style-ghost', '--xt-catalog-border-width-own', '--xt-catalog-border-width-watched', '--xt-catalog-border-style-own', '--xt-catalog-border-style-watched', '--xt-catalog-own-highlight', '--xt-catalog-own-highlight-opacity', '--xt-catalog-watched-highlight', '--xt-catalog-watched-highlight-opacity', '--xt-scroll-marker-own', '--xt-scroll-marker-you', '--xt-scroll-marker-ghost', '--xt-scroll-marker-unread', '--xt-scroll-marker-own-opacity', '--xt-scroll-marker-you-opacity', '--xt-scroll-marker-ghost-opacity', '--xt-scroll-marker-unread-opacity', '--xt-text-color', '--xt-link-text-color', '--xt-quote-text-color', '--xt-dead-link-text-color', '--xt-highlight-own-text', '--xt-highlight-own-link', '--xt-highlight-own-quote', '--xt-highlight-own-dead-link', '--xt-highlight-you-text', '--xt-highlight-you-link', '--xt-highlight-you-quote', '--xt-highlight-you-dead-link', '--xt-highlight-ghost-text', '--xt-highlight-ghost-link', '--xt-highlight-ghost-quote', '--xt-highlight-ghost-dead-link', '--xt-catalog-own-text', '--xt-catalog-own-subject', '--xt-catalog-own-link', '--xt-catalog-own-quote', '--xt-catalog-own-dead-link', '--xt-catalog-watched-text', '--xt-catalog-watched-subject', '--xt-catalog-watched-link', '--xt-catalog-watched-quote', '--xt-catalog-watched-dead-link', ], clearStyleVarsOn(target) { let removed = false; for (const name of Settings.STYLE_VAR_NAMES) { if (target.style.getPropertyValue(name)) { target.style.removeProperty(name); removed = true; } } return removed; }, writeStyleVarsTo(target, variant, updateRootClasses) { const styleVarValue = (value) => value === Settings.THEME_BORDER_HIGHLIGHT ? 'var(--xt-border-highlight)' : value; let varsChanged = false; const setVar = (cssVar, value) => { const resolved = styleVarValue(value); const current = target.style.getPropertyValue(cssVar); if (resolved) { if (current !== String(resolved)) { target.style.setProperty(cssVar, resolved); varsChanged = true; } } else if (current) { target.style.removeProperty(cssVar); varsChanged = true; } }; const cv = (key) => Settings.styleConf(key, variant); const highlightsOn = Settings.stylingSectionEnabled('highlights'); const markersOn = Settings.stylingSectionEnabled('scrollbarMarkers'); const textColorsOn = Settings.stylingSectionEnabled('textColors'); const threadHighlightsEnabled = highlightsOn && Conf['Enable Thread Highlights'] !== false; const catalogHighlightsEnabled = highlightsOn && Conf['Enable Catalog Highlights'] !== false; const catalogOwnEnabled = catalogHighlightsEnabled && Conf['Catalog Highlight Own Posts'] !== false; const catalogWatchedEnabled = catalogHighlightsEnabled && Conf['Catalog Highlight Watched Threads'] !== false; const catalogFilterEnabled = highlightsOn && Conf['Catalog Highlight Filter Posts'] !== false; if (updateRootClasses) { doc.classList.toggle('highlight-own', threadHighlightsEnabled && !!Conf['Highlight Own Posts']); doc.classList.toggle('highlight-you', threadHighlightsEnabled && !!Conf['Highlight Posts Quoting You']); doc.classList.toggle('highlight-ghost', threadHighlightsEnabled && !!Conf['Highlight Ghost Posts']); doc.classList.toggle('xt-set-own-highlight', threadHighlightsEnabled && !!Conf['Highlight Own Posts'] && !!cv('Highlight Own Color')); doc.classList.toggle('xt-set-you-highlight', threadHighlightsEnabled && !!Conf['Highlight Posts Quoting You'] && !!cv('Highlight You Color')); doc.classList.toggle('xt-set-ghost-highlight', threadHighlightsEnabled && !!Conf['Highlight Ghost Posts'] && !!cv('Highlight Ghost Color')); doc.classList.toggle('xt-highlight-catalog-own', catalogOwnEnabled); doc.classList.toggle('xt-highlight-catalog-watched', catalogWatchedEnabled); doc.classList.toggle('xt-catalog-edge-own', catalogOwnEnabled && !cv('Catalog Highlight Own Background')); doc.classList.toggle('xt-catalog-edge-watched', catalogWatchedEnabled && !cv('Catalog Highlight Watched Background')); doc.classList.toggle('xt-catalog-own-loc-image', catalogOwnEnabled && cv('Catalog Highlight Own Location') === 'image'); doc.classList.toggle('xt-catalog-watched-loc-image', catalogWatchedEnabled && cv('Catalog Highlight Watched Location') === 'image'); doc.classList.toggle('xt-catalog-own-glow', catalogOwnEnabled && !!cv('Catalog Highlight Own Glow')); doc.classList.toggle('xt-catalog-watched-glow', catalogWatchedEnabled && !!cv('Catalog Highlight Watched Glow')); doc.classList.toggle('xt-highlight-catalog-filter', catalogFilterEnabled); doc.classList.toggle('xt-set-catalog-filter-highlight', catalogFilterEnabled && !!cv('Catalog Highlight Filter Color')); doc.classList.toggle('xt-catalog-edge-filter', catalogFilterEnabled && !cv('Catalog Highlight Filter Background')); doc.classList.toggle('xt-catalog-filter-loc-image', catalogFilterEnabled && cv('Catalog Highlight Filter Location') === 'image'); doc.classList.toggle('xt-catalog-filter-glow-off', !(catalogFilterEnabled && !!cv('Catalog Highlight Filter Glow'))); doc.classList.toggle('xt-edge-own', highlightsOn && !Conf['Highlight Own Background']); doc.classList.toggle('xt-edge-you', highlightsOn && !Conf['Highlight You Background']); doc.classList.toggle('xt-edge-ghost', highlightsOn && !Conf['Highlight Ghost Background']); } setVar('--xt-highlight-own', cv('Highlight Own Color')); setVar('--xt-highlight-you', cv('Highlight You Color')); setVar('--xt-highlight-ghost', cv('Highlight Ghost Color')); setVar('--xt-highlight-own-opacity', cv('Highlight Own Opacity') === '' ? '' : String(cv('Highlight Own Opacity'))); setVar('--xt-highlight-you-opacity', cv('Highlight You Opacity') === '' ? '' : String(cv('Highlight You Opacity'))); setVar('--xt-highlight-ghost-opacity', cv('Highlight Ghost Opacity') === '' ? '' : String(cv('Highlight Ghost Opacity'))); const legacyWidth = Settings.styleConf('Highlight Edge Width', variant); const edgeWidth = parseFloat(String(cv('Thread Highlight Edge Width') || legacyWidth)); setVar('--xt-highlight-edge-width', Number.isFinite(edgeWidth) ? `${$.minmax(edgeWidth, 1, 12)}px` : ''); setVar('--xt-post-background', Settings.getPostBaseBackgroundCSS()); const catalogBorderWidth = parseFloat(String(cv('Catalog Highlight Border Width') || legacyWidth)); setVar('--xt-catalog-border-width', Number.isFinite(catalogBorderWidth) ? `${$.minmax(catalogBorderWidth, 1, 12)}px` : ''); const setWidthVar = (key, varName, min = 1) => { const w = parseFloat(String(cv(key))); setVar(varName, Number.isFinite(w) ? `${$.minmax(w, min, 12)}px` : ''); }; const setStyleVar = (key, varName) => { const style = String(cv(key) || ''); setVar(varName, /^(solid|dashed|dotted|double|groove|ridge|inset|outset)$/.test(style) ? style : ''); }; setWidthVar('Highlight Own Edge Width', '--xt-edge-width-own'); setWidthVar('Highlight You Edge Width', '--xt-edge-width-you'); setWidthVar('Highlight Ghost Edge Width', '--xt-edge-width-ghost'); setStyleVar('Highlight Own Border Style', '--xt-edge-style-own'); setStyleVar('Highlight You Border Style', '--xt-edge-style-you'); setStyleVar('Highlight Ghost Border Style', '--xt-edge-style-ghost'); setWidthVar('Catalog Highlight Own Border Width', '--xt-catalog-border-width-own', 0); setWidthVar('Catalog Highlight Watched Border Width', '--xt-catalog-border-width-watched', 0); setStyleVar('Catalog Highlight Own Border Style', '--xt-catalog-border-style-own'); setStyleVar('Catalog Highlight Watched Border Style', '--xt-catalog-border-style-watched'); setWidthVar('Catalog Highlight Filter Border Width', '--xt-catalog-border-width-filter', 0); setStyleVar('Catalog Highlight Filter Border Style', '--xt-catalog-border-style-filter'); setVar('--xt-catalog-own-highlight', cv('Catalog Highlight Own Color')); setVar('--xt-catalog-own-highlight-opacity', cv('Catalog Highlight Own Opacity') !== '' ? String(cv('Catalog Highlight Own Opacity')) : ''); setVar('--xt-catalog-watched-highlight', cv('Catalog Highlight Watched Color')); setVar('--xt-catalog-watched-highlight-opacity', cv('Catalog Highlight Watched Opacity') !== '' ? String(cv('Catalog Highlight Watched Opacity')) : ''); setVar('--xt-catalog-filter-highlight', cv('Catalog Highlight Filter Color')); setVar('--xt-catalog-filter-highlight-opacity', cv('Catalog Highlight Filter Opacity') !== '' ? String(cv('Catalog Highlight Filter Opacity')) : ''); const glowVar = (cssVar, key) => setVar(cssVar, cv(key) !== '' && cv(key) != null ? String(cv(key)) : ''); glowVar('--xt-catalog-own-glow-intensity', 'Catalog Highlight Own Glow Intensity'); glowVar('--xt-catalog-watched-glow-intensity', 'Catalog Highlight Watched Glow Intensity'); glowVar('--xt-catalog-filter-glow-intensity', 'Catalog Highlight Filter Glow Intensity'); const ownMarkerLinked = !!cv('Scroll Marker Own Match Highlight'); const youMarkerLinked = !!cv('Scroll Marker You Match Highlight'); const ghostMarkerLinked = !!cv('Scroll Marker Ghost Match Highlight'); const markerVar = (cssVar, value) => setVar(cssVar, markersOn && value !== '' ? String(value) : ''); markerVar('--xt-scroll-marker-own', ownMarkerLinked ? cv('Highlight Own Color') : cv('Scroll Marker Own Color')); markerVar('--xt-scroll-marker-you', youMarkerLinked ? cv('Highlight You Color') : cv('Scroll Marker You Color')); markerVar('--xt-scroll-marker-ghost', ghostMarkerLinked ? cv('Highlight Ghost Color') : cv('Scroll Marker Ghost Color')); markerVar('--xt-scroll-marker-unread', cv('Scroll Marker Unread Color')); markerVar('--xt-scroll-marker-own-opacity', cv('Scroll Marker Own Opacity')); markerVar('--xt-scroll-marker-you-opacity', cv('Scroll Marker You Opacity')); markerVar('--xt-scroll-marker-ghost-opacity', cv('Scroll Marker Ghost Opacity')); markerVar('--xt-scroll-marker-unread-opacity', cv('Scroll Marker Unread Opacity')); const baseBackground = Settings.getTextBaseBackground(); const postBackground = Settings.getPostBaseBackground(); const textColorMode = Settings.resolveTextMode(cv('textColorMode')); const autoTextPalette = Settings.autoTextPalette(baseBackground); const textColorsOverride = textColorsOn && textColorMode !== 'default'; const textColor = !textColorsOverride ? '' : (textColorMode === 'auto' ? autoTextPalette.text : cv('Text Color')); const linkColor = !textColorsOverride ? '' : (textColorMode === 'auto' ? autoTextPalette.link : cv('Link Text Color')); const quoteColor = !textColorsOverride ? '' : (textColorMode === 'auto' ? autoTextPalette.quote : cv('Quote Text Color')); const deadLinkColor = !textColorsOverride ? '' : (textColorMode === 'auto' ? autoTextPalette.deadLink : cv('Dead Link Text Color')); const hasAnyTextOverride = !!(textColor || linkColor || quoteColor || deadLinkColor); const anyThreadHighlightText = highlightsOn && [ 'Highlight Own Text Mode', 'Highlight You Text Mode', 'Highlight Ghost Text Mode', ].some(k => Settings.resolveTextMode(cv(k)) !== 'default'); if (updateRootClasses) { if (hasAnyTextOverride || anyThreadHighlightText) { $.addClass(doc, 'xt-custom-text-colors'); } else { $.rmClass(doc, 'xt-custom-text-colors'); } } setVar('--xt-text-color', textColor || ''); setVar('--xt-link-text-color', linkColor || ''); setVar('--xt-quote-text-color', quoteColor || ''); setVar('--xt-dead-link-text-color', deadLinkColor || ''); const ownEdgeOnly = highlightsOn && !Conf['Highlight Own Background']; const youEdgeOnly = highlightsOn && !Conf['Highlight You Background']; const ghostEdgeOnly = highlightsOn && !Conf['Highlight Ghost Background']; const catalogOwnBorderOnly = catalogOwnEnabled && !cv('Catalog Highlight Own Background'); const catalogWatchedBorderOnly = catalogWatchedEnabled && !cv('Catalog Highlight Watched Background'); const catalogFilterBorderOnly = catalogFilterEnabled && !cv('Catalog Highlight Filter Background'); const highlightOpacity = (opacityKey) => { const opacity = cv(opacityKey); if (opacity === '' || opacity == null) return 1; const alpha = parseFloat(String(opacity)); return Number.isFinite(alpha) ? $.minmax(alpha, 0, 1) : 1; }; const withManual = (autoPalette, modeKey, textKey, subjectKey, linkKey, quoteKey, deadKey) => { const base = autoPalette || { text: textColor || '', subject: textColor || '', link: linkColor || '', quote: quoteColor || '', deadLink: deadLinkColor || '', }; const mode = Settings.resolveTextMode(cv(modeKey)); if (mode === 'default') return null; if (mode === 'auto') return base; return { text: cv(textKey) || base.text, subject: (subjectKey ? cv(subjectKey) : '') || base.subject || base.text, link: cv(linkKey) || base.link, quote: cv(quoteKey) || base.quote, deadLink: cv(deadKey) || base.deadLink, }; }; const ownPalette = withManual(ownEdgeOnly ? null : Settings.autoHighlightTextPalette('Highlight Own Color', 'Highlight Own Opacity', postBackground, variant), 'Highlight Own Text Mode', 'Highlight Own Text Color', null, 'Highlight Own Link Color', 'Highlight Own Quote Color', 'Highlight Own Dead Link Color'); const youPalette = withManual(youEdgeOnly ? null : Settings.autoHighlightTextPalette('Highlight You Color', 'Highlight You Opacity', postBackground, variant), 'Highlight You Text Mode', 'Highlight You Text Color', null, 'Highlight You Link Color', 'Highlight You Quote Color', 'Highlight You Dead Link Color'); const ghostPalette = withManual(ghostEdgeOnly ? null : Settings.autoHighlightTextPalette('Highlight Ghost Color', 'Highlight Ghost Opacity', postBackground, variant), 'Highlight Ghost Text Mode', 'Highlight Ghost Text Color', null, 'Highlight Ghost Link Color', 'Highlight Ghost Quote Color', 'Highlight Ghost Dead Link Color'); const catalogOwnTextActive = catalogOwnEnabled && !catalogOwnBorderOnly && highlightOpacity('Catalog Highlight Own Opacity') > 0 && Settings.resolveTextMode(cv('Catalog Highlight Own Text Mode')) !== 'default'; const catalogWatchedTextActive = catalogWatchedEnabled && !catalogWatchedBorderOnly && highlightOpacity('Catalog Highlight Watched Opacity') > 0 && Settings.resolveTextMode(cv('Catalog Highlight Watched Text Mode')) !== 'default'; const catalogFilterTextActive = catalogFilterEnabled && !catalogFilterBorderOnly && highlightOpacity('Catalog Highlight Filter Opacity') > 0 && Settings.resolveTextMode(cv('Catalog Highlight Filter Text Mode')) !== 'default'; if (updateRootClasses) { doc.classList.toggle('xt-catalog-own-text-colors', catalogOwnTextActive); doc.classList.toggle('xt-catalog-watched-text-colors', catalogWatchedTextActive); doc.classList.toggle('xt-catalog-filter-text-colors', catalogFilterTextActive); } const catalogOwnPalette = withManual(catalogOwnTextActive ? Settings.autoHighlightTextPalette('Catalog Highlight Own Color', 'Catalog Highlight Own Opacity', postBackground, variant) : null, 'Catalog Highlight Own Text Mode', 'Catalog Highlight Own Text Color', 'Catalog Highlight Own Subject Color', 'Catalog Highlight Own Link Color', 'Catalog Highlight Own Quote Color', 'Catalog Highlight Own Dead Link Color'); const catalogWatchedPalette = withManual(catalogWatchedTextActive ? Settings.autoHighlightTextPalette('Catalog Highlight Watched Color', 'Catalog Highlight Watched Opacity', postBackground, variant) : null, 'Catalog Highlight Watched Text Mode', 'Catalog Highlight Watched Text Color', 'Catalog Highlight Watched Subject Color', 'Catalog Highlight Watched Link Color', 'Catalog Highlight Watched Quote Color', 'Catalog Highlight Watched Dead Link Color'); const catalogFilterPalette = withManual(catalogFilterTextActive ? Settings.autoHighlightTextPalette('Catalog Highlight Filter Color', 'Catalog Highlight Filter Opacity', postBackground, variant) : null, 'Catalog Highlight Filter Text Mode', 'Catalog Highlight Filter Text Color', 'Catalog Highlight Filter Subject Color', 'Catalog Highlight Filter Link Color', 'Catalog Highlight Filter Quote Color', 'Catalog Highlight Filter Dead Link Color'); setVar('--xt-highlight-own-text', ownPalette?.text || ''); setVar('--xt-highlight-own-link', ownPalette?.link || ''); setVar('--xt-highlight-own-quote', ownPalette?.quote || ''); setVar('--xt-highlight-own-dead-link', ownPalette?.deadLink || ''); setVar('--xt-highlight-you-text', youPalette?.text || ''); setVar('--xt-highlight-you-link', youPalette?.link || ''); setVar('--xt-highlight-you-quote', youPalette?.quote || ''); setVar('--xt-highlight-you-dead-link', youPalette?.deadLink || ''); setVar('--xt-highlight-ghost-text', ghostPalette?.text || ''); setVar('--xt-highlight-ghost-link', ghostPalette?.link || ''); setVar('--xt-highlight-ghost-quote', ghostPalette?.quote || ''); setVar('--xt-highlight-ghost-dead-link', ghostPalette?.deadLink || ''); setVar('--xt-catalog-own-text', catalogOwnPalette?.text || ''); setVar('--xt-catalog-own-subject', catalogOwnPalette?.subject || ''); setVar('--xt-catalog-own-link', catalogOwnPalette?.link || ''); setVar('--xt-catalog-own-quote', catalogOwnPalette?.quote || ''); setVar('--xt-catalog-own-dead-link', catalogOwnPalette?.deadLink || ''); setVar('--xt-catalog-watched-text', catalogWatchedPalette?.text || ''); setVar('--xt-catalog-watched-subject', catalogWatchedPalette?.subject || ''); setVar('--xt-catalog-watched-link', catalogWatchedPalette?.link || ''); setVar('--xt-catalog-watched-quote', catalogWatchedPalette?.quote || ''); setVar('--xt-catalog-watched-dead-link', catalogWatchedPalette?.deadLink || ''); setVar('--xt-catalog-filter-text', catalogFilterPalette?.text || ''); setVar('--xt-catalog-filter-subject', catalogFilterPalette?.subject || ''); setVar('--xt-catalog-filter-link', catalogFilterPalette?.link || ''); setVar('--xt-catalog-filter-quote', catalogFilterPalette?.quote || ''); setVar('--xt-catalog-filter-dead-link', catalogFilterPalette?.deadLink || ''); return varsChanged; }, autoTextPalette(rgb) { const bg = rgb || Settings.getTextBaseBackground(); const lightText = '#f2f2f2'; const darkText = '#111111'; const lightContrast = Settings.contrastRatio(Settings.hexToRgb(lightText), bg); const darkContrast = Settings.contrastRatio(Settings.hexToRgb(darkText), bg); const useLight = lightContrast >= darkContrast; const text = useLight ? lightText : darkText; const palette = useLight ? { text, link: '#9cc1ff', quote: '#a8dd72', deadLink: '#b8c5ff', } : { text, link: '#0b52d6', quote: '#2f7d1a', deadLink: '#4c63be', }; const minRatio = 3; const safe = (hex) => { const c = Settings.hexToRgb(hex); return c && (Settings.contrastRatio(c, bg) >= minRatio) ? hex : text; }; return { text, subject: text, link: safe(palette.link), quote: safe(palette.quote), deadLink: safe(palette.deadLink), }; }, hexToRgb(value) { const hex = value?.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1]; if (!hex) return null; if (hex.length === 3) { return [ parseInt(`${hex[0]}${hex[0]}`, 16), parseInt(`${hex[1]}${hex[1]}`, 16), parseInt(`${hex[2]}${hex[2]}`, 16), ]; } return [ parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), ]; }, mixRgb(base, overlay, alpha) { const a = $.minmax(alpha, 0, 1); const out = [0, 1, 2].map(i => Math.round((base[i] * (1 - a)) + (overlay[i] * a))); return [out[0], out[1], out[2]]; }, contrastRatio(fg, bg) { const l1 = Settings.relativeLuminance(fg); const l2 = Settings.relativeLuminance(bg); const hi = Math.max(l1, l2); const lo = Math.min(l1, l2); return (hi + 0.05) / (lo + 0.05); }, relativeLuminance(rgb) { const toLinear = (channel) => { const c = channel / 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; const [r, g, b] = rgb.map(toLinear); return (0.2126 * r) + (0.7152 * g) + (0.0722 * b); }, parseCSSColorRGBA(value) { if (!value) return null; const input = value.trim().toLowerCase(); if (!input) return null; if (input === 'transparent') return [0, 0, 0, 0]; const rgbBody = input.match(/^rgba?\((.+)\)$/i)?.[1]; if (rgbBody) { const tokens = rgbBody.match(/[\d.]+%?/g); if (!tokens || tokens.length < 3) return null; const toChannel = (token) => { const num = parseFloat(token); if (!Number.isFinite(num)) return NaN; return token.endsWith('%') ? ((num / 100) * 255) : num; }; const r = toChannel(tokens[0]); const g = toChannel(tokens[1]); const b = toChannel(tokens[2]); if (![r, g, b].every(Number.isFinite)) return null; const alphaToken = tokens[3]; const alpha = alphaToken == null ? 1 : (alphaToken.endsWith('%') ? (parseFloat(alphaToken) / 100) : parseFloat(alphaToken)); if (!Number.isFinite(alpha)) return null; return [r, g, b, $.minmax(alpha, 0, 1)]; } const hex = Settings.hexToRgb(input); if (hex) return [hex[0], hex[1], hex[2], 1]; return null; }, isTransparentCSSColor(value) { const rgba = Settings.parseCSSColorRGBA(value); return !rgba || rgba[3] <= 0; }, backgroundStyleFromComputed(style) { return { backgroundColor: style.backgroundColor || 'transparent', backgroundImage: style.backgroundImage || 'none', backgroundRepeat: style.backgroundRepeat || 'repeat', backgroundPosition: style.backgroundPosition || '0% 0%', backgroundSize: style.backgroundSize || 'auto', backgroundAttachment: style.backgroundAttachment || 'scroll', }; }, resolveCanvasBackgroundStyle() { const cache = Settings.styleBgCache; if (cache?.canvas) return cache.canvas; const htmlStyle = Settings.backgroundStyleFromComputed(window.getComputedStyle(d.documentElement)); if (!d.body) return htmlStyle; const bodyStyle = Settings.backgroundStyleFromComputed(window.getComputedStyle(d.body)); const htmlDefersToBody = (htmlStyle.backgroundImage === 'none' && Settings.isTransparentCSSColor(htmlStyle.backgroundColor)); const result = htmlDefersToBody ? bodyStyle : htmlStyle; if (cache) cache.canvas = result; return result; }, applyBackgroundStyle(el, background) { el.style.backgroundColor = background.backgroundColor; el.style.backgroundImage = background.backgroundImage; el.style.backgroundRepeat = background.backgroundRepeat; el.style.backgroundPosition = background.backgroundPosition; el.style.backgroundSize = background.backgroundSize; el.style.backgroundAttachment = background.backgroundAttachment; }, styleBgCache: null, getTextBaseBackground() { const cache = Settings.styleBgCache; if (cache?.text) return cache.text; const style = Settings.resolveCanvasBackgroundStyle(); const rgba = Settings.parseCSSColorRGBA(style.backgroundColor); const result = rgba && rgba[3] > 0 ? [rgba[0], rgba[1], rgba[2]] : [255, 255, 255]; if (cache) cache.text = result; return result; }, getPostBaseBackground() { const cache = Settings.styleBgCache; if (cache?.post) return cache.post; const fallback = Settings.getTextBaseBackground(); let bgColor = ''; try { const el = g.SITE?.bgColoredEl?.(); if (el && d.body) { el.style.cssText = 'position:absolute;left:-9999px;top:-9999px;visibility:hidden;pointer-events:none;'; const host = Settings.getStyleProbeHost(d.body); $.add(host, el); bgColor = window.getComputedStyle(el).backgroundColor; host.textContent = ''; } } catch (e) { bgColor = ''; } const rgba = Settings.parseCSSColorRGBA(bgColor); let result; if (!rgba || rgba[3] <= 0) result = fallback; else { const postRgb = [rgba[0], rgba[1], rgba[2]]; result = rgba[3] >= 1 ? postRgb : Settings.mixRgb(fallback, postRgb, rgba[3]); } if (cache) cache.post = result; return result; }, getPostBaseBackgroundCSS() { const bg = Settings.getPostBaseBackground(); return `rgb(${bg[0]}, ${bg[1]}, ${bg[2]})`; }, autoHighlightTextPalette(colorKey, opacityKey, baseBackground = Settings.getTextBaseBackground(), variant) { const color = Settings.styleConf(colorKey, variant); const rgb = Settings.hexToRgb(color); if (!rgb) return null; const opacity = Settings.styleConf(opacityKey, variant); const alpha = (opacity === '' || opacity == null) ? 1 : $.minmax(parseFloat(String(opacity)), 0, 1); if (!Number.isFinite(alpha) || alpha <= 0) return null; return Settings.autoTextPalette(Settings.mixRgb(baseBackground, rgb, alpha)); }, colorExpressionForKey(key) { switch (Settings.styleKeyBase(key)) { case 'Highlight Own Color': return 'var(--xt-highlight-own, var(--xt-border-highlight, #d83030))'; case 'Highlight You Color': return 'var(--xt-highlight-you, var(--xt-border-highlight, #ff5050))'; case 'Highlight Ghost Color': return 'var(--xt-highlight-ghost, #888888)'; case 'Catalog Highlight Own Color': return 'var(--xt-catalog-own-highlight, var(--xt-highlight-own, #d83030))'; case 'Catalog Highlight Watched Color': return 'var(--xt-catalog-watched-highlight, var(--xt-watched-border, rgba(255, 0, 0, .75)))'; case 'Catalog Highlight Filter Color': return 'var(--xt-catalog-filter-highlight, var(--xt-highlight-shadow, var(--xt-filter-highlight, rgba(221, 0, 0, .75))))'; case 'Scroll Marker Own Color': return 'var(--xt-scroll-marker-own, var(--xt-border-highlight, #d83030))'; case 'Scroll Marker You Color': return 'var(--xt-scroll-marker-you, var(--xt-border-highlight, #ff5050))'; case 'Scroll Marker Ghost Color': return 'var(--xt-scroll-marker-ghost, #888888)'; case 'Scroll Marker Unread Color': return 'var(--xt-scroll-marker-unread, #ffd400)'; case 'Text Color': return 'var(--xt-text-color, #111111)'; case 'Link Text Color': return 'var(--xt-link-text-color, #0b52d6)'; case 'Quote Text Color': return 'var(--xt-quote-text-color, #2f7d1a)'; case 'Dead Link Text Color': return 'var(--xt-dead-link-text-color, #4c63be)'; case 'Highlight Own Text Color': return 'var(--xt-highlight-own-text, var(--xt-text-color, #111111))'; case 'Highlight Own Link Color': return 'var(--xt-highlight-own-link, var(--xt-link-text-color, #0b52d6))'; case 'Highlight Own Quote Color': return 'var(--xt-highlight-own-quote, var(--xt-quote-text-color, #2f7d1a))'; case 'Highlight Own Dead Link Color': return 'var(--xt-highlight-own-dead-link, var(--xt-dead-link-text-color, #4c63be))'; case 'Highlight You Text Color': return 'var(--xt-highlight-you-text, var(--xt-text-color, #111111))'; case 'Highlight You Link Color': return 'var(--xt-highlight-you-link, var(--xt-link-text-color, #0b52d6))'; case 'Highlight You Quote Color': return 'var(--xt-highlight-you-quote, var(--xt-quote-text-color, #2f7d1a))'; case 'Highlight You Dead Link Color': return 'var(--xt-highlight-you-dead-link, var(--xt-dead-link-text-color, #4c63be))'; case 'Highlight Ghost Text Color': return 'var(--xt-highlight-ghost-text, var(--xt-text-color, #111111))'; case 'Highlight Ghost Link Color': return 'var(--xt-highlight-ghost-link, var(--xt-link-text-color, #0b52d6))'; case 'Highlight Ghost Quote Color': return 'var(--xt-highlight-ghost-quote, var(--xt-quote-text-color, #2f7d1a))'; case 'Highlight Ghost Dead Link Color': return 'var(--xt-highlight-ghost-dead-link, var(--xt-dead-link-text-color, #4c63be))'; case 'Catalog Highlight Own Text Color': return 'var(--xt-catalog-own-text, var(--xt-text-color, #111111))'; case 'Catalog Highlight Own Subject Color': return 'var(--xt-catalog-own-subject, var(--xt-catalog-own-text, var(--xt-text-color, #111111)))'; case 'Catalog Highlight Own Link Color': return 'var(--xt-catalog-own-link, var(--xt-link-text-color, #0b52d6))'; case 'Catalog Highlight Own Quote Color': return 'var(--xt-catalog-own-quote, var(--xt-quote-text-color, #2f7d1a))'; case 'Catalog Highlight Own Dead Link Color': return 'var(--xt-catalog-own-dead-link, var(--xt-dead-link-text-color, #4c63be))'; case 'Catalog Highlight Watched Text Color': return 'var(--xt-catalog-watched-text, var(--xt-text-color, #111111))'; case 'Catalog Highlight Watched Subject Color': return 'var(--xt-catalog-watched-subject, var(--xt-catalog-watched-text, var(--xt-text-color, #111111)))'; case 'Catalog Highlight Watched Link Color': return 'var(--xt-catalog-watched-link, var(--xt-link-text-color, #0b52d6))'; case 'Catalog Highlight Watched Quote Color': return 'var(--xt-catalog-watched-quote, var(--xt-quote-text-color, #2f7d1a))'; case 'Catalog Highlight Watched Dead Link Color': return 'var(--xt-catalog-watched-dead-link, var(--xt-dead-link-text-color, #4c63be))'; default: return ''; } }, toHexColor(value) { if (!value) return null; const hex = value.trim().toLowerCase(); const longHex = hex.match(/^#([0-9a-f]{6})$/i)?.[0]; if (longHex) return longHex.toLowerCase(); const shortHex = hex.match(/^#([0-9a-f]{3})$/i)?.[1]; if (shortHex) { return `#${shortHex[0]}${shortHex[0]}${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}`.toLowerCase(); } const rgb = hex.match(/^rgba?\(([^)]+)\)$/i)?.[1]; if (!rgb) return null; const parts = rgb.split(',').map(part => parseFloat(part.trim())); if (parts.length < 3 || parts.slice(0, 3).some(part => !Number.isFinite(part))) return null; const to = (n) => Math.round($.minmax(n, 0, 255)).toString(16).padStart(2, '0'); return `#${to(parts[0])}${to(parts[1])}${to(parts[2])}`; }, normalizeHexColorInput(value) { const input = value.trim().replace(/^#/, ''); if (/^[0-9a-f]{3}$/i.test(input)) { return `#${input[0]}${input[0]}${input[1]}${input[1]}${input[2]}${input[2]}`.toLowerCase(); } if (/^[0-9a-f]{6}$/i.test(input)) { return `#${input}`.toLowerCase(); } return null; }, getStyleProbeHost(scope) { const hosts = Settings.styleProbeHosts || (Settings.styleProbeHosts = new WeakMap()); let host = hosts.get(scope); if (!host || host.parentNode !== scope) { host = $.el('div'); host.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;left:0;top:0'; $.add(scope, host); hosts.set(scope, host); } return host; }, primeResolvedStyleColorCache(keys) { if (!keys.length || !d.body) return; const cache = Settings.resolvedStyleColorCache || (Settings.resolvedStyleColorCache = dict()); const unresolvedKeys = Array.from(new Set(keys .map(key => Settings.styleKeyBase(key)) .filter(key => !(key in cache) && !!Settings.colorExpressionForKey(key)))); if (!unresolvedKeys.length) return; const scope = Settings.dialog || d.body; const host = Settings.getStyleProbeHost(scope); const probes = dict(); for (const key of unresolvedKeys) { const probe = $.el('span'); probe.style.color = Settings.colorExpressionForKey(key); probes[key] = probe; $.add(host, probe); } for (const key of unresolvedKeys) { const color = Settings.toHexColor(window.getComputedStyle(probes[key]).color); if (color) cache[key] = color; } host.textContent = ''; }, resolvedColorForKey(key) { const baseKey = Settings.styleKeyBase(key); Settings.primeResolvedStyleColorCache([baseKey]); return Settings.resolvedStyleColorCache?.[baseKey] || null; }, setColorInputValue(input, key, rawValue) { const explicit = (typeof rawValue === 'string' && /^#[0-9a-f]{6}$/i.test(rawValue)) ? rawValue.toLowerCase() : null; input.value = explicit || Settings.resolvedColorForKey(key) || '#000000'; if (explicit) { delete input.dataset.unset; } else { input.dataset.unset = '1'; } const hexInput = input.nextElementSibling; if (hexInput?.classList.contains('styling-color-hex')) { hexInput.value = input.value || ''; hexInput.disabled = input.disabled; hexInput.classList.remove('styling-color-hex-invalid'); } }, refreshUnsetStylingColorInputs() { if (!Settings.dialog) return; const inputs = $$('#fourchanx-settings input[type="color"][name][data-unset="1"]', Settings.dialog); Settings.primeResolvedStyleColorCache(inputs.map(input => input.name)); for (const input of inputs) { Settings.setColorInputValue(input, input.name, ''); } }, syncLinkedMarkerColors(inputs, variant) { const colorPairs = [ ['Highlight Own Color', 'Scroll Marker Own Color', 'Scroll Marker Own Match Highlight'], ['Highlight You Color', 'Scroll Marker You Color', 'Scroll Marker You Match Highlight'], ['Highlight Ghost Color', 'Scroll Marker Ghost Color', 'Scroll Marker Ghost Match Highlight'], ]; for (const [highlightKey, markerKey, matchKey] of colorPairs) { const linked = !!Settings.styleConf(matchKey, variant); const color = linked ? (Settings.styleConf(highlightKey, variant) || '') : (Settings.styleConf(markerKey, variant) || ''); const markerStorageKey = Settings.variantKey(markerKey, variant); if (linked && (Conf[markerStorageKey] !== color)) { Conf[markerStorageKey] = color; $.set(markerStorageKey, color); } const markerInput = inputs?.[markerKey] || $(`#fourchanx-settings [name="${markerStorageKey}"]`); if (!markerInput) continue; Settings.setColorInputValue(markerInput, markerKey, color); } }, randomHighlightColor() { const h = Math.floor(Math.random() * 360); const s = 55 + Math.floor(Math.random() * 25); const l = 40 + Math.floor(Math.random() * 25); const c = (1 - Math.abs(2 * l / 100 - 1)) * (s / 100); const hh = h / 60; const x = c * (1 - Math.abs((hh % 2) - 1)); let r = 0, g = 0, b = 0; if (hh < 1) { r = c; g = x; } else if (hh < 2) { r = x; g = c; } else if (hh < 3) { g = c; b = x; } else if (hh < 4) { g = x; b = c; } else if (hh < 5) { r = x; b = c; } else { r = c; b = x; } const m = l / 100 - c / 2; const to = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0'); return `#${to(r)}${to(g)}${to(b)}`; }, hslToHex(h, s, l) { const hue = ((h % 360) + 360) % 360; const sat = $.minmax(s, 0, 100) / 100; const light = $.minmax(l, 0, 100) / 100; const c = (1 - Math.abs(2 * light - 1)) * sat; const hh = hue / 60; const x = c * (1 - Math.abs((hh % 2) - 1)); let r = 0, g = 0, b = 0; if (hh < 1) { r = c; g = x; } else if (hh < 2) { r = x; g = c; } else if (hh < 3) { g = c; b = x; } else if (hh < 4) { g = x; b = c; } else if (hh < 5) { r = x; b = c; } else { r = c; b = x; } const m = light - c / 2; const to = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0'); return `#${to(r)}${to(g)}${to(b)}`; }, highlightPaletteThemeProfile(variant) { const siteStyle = String(Settings.styleConf('siteStyle', variant) || '').trim(); const native = Settings.isCustomSiteThemeValue(siteStyle) ? '' : siteStyle.toLowerCase(); const darkThemes = ['tomorrow', 'spooky', 'photon']; const lightThemes = ['yotsuba-b', 'yotsuba', 'futaba', 'burichan']; if (native) { if (darkThemes.some(name => native === name || native.includes(name))) { return { kind: 'dark', label: Settings.nativeSiteThemeLabel(siteStyle), note: 'based on the selected site style', }; } if (lightThemes.some(name => native === name || native.includes(name))) { return { kind: 'light', label: Settings.nativeSiteThemeLabel(siteStyle), note: 'based on the selected site style', }; } } const bg = Settings.getTextBaseBackground(); const luminance = Settings.relativeLuminance(bg); const kind = luminance < 0.42 ? 'dark' : 'light'; return { kind, label: kind === 'dark' ? 'dark background' : 'light background', note: 'based on the current page background', }; }, suggestedHighlightPalettes(variant, batch = 0) { const profile = Settings.highlightPaletteThemeProfile(variant); const dark = [ { id: 'ember-night', name: 'Ember Night', colors: { own: '#ff6b6b', you: '#ff9f43', ghost: '#9ca3af', catalogOwn: '#2dd4bf', catalogWatched: '#60a5fa' } }, { id: 'aurora', name: 'Aurora', colors: { own: '#22d3ee', you: '#a78bfa', ghost: '#94a3b8', catalogOwn: '#f472b6', catalogWatched: '#facc15' } }, { id: 'mint-ember', name: 'Mint Ember', colors: { own: '#34d399', you: '#fb7185', ghost: '#a1a1aa', catalogOwn: '#5eead4', catalogWatched: '#f59e0b' } }, { id: 'blue-steel', name: 'Blue Steel', colors: { own: '#60a5fa', you: '#f97316', ghost: '#9aa4b2', catalogOwn: '#38bdf8', catalogWatched: '#f43f5e' } }, { id: 'violet-lime', name: 'Violet Lime', colors: { own: '#c084fc', you: '#4ade80', ghost: '#a3a3a3', catalogOwn: '#818cf8', catalogWatched: '#fbbf24' } }, ]; const light = [ { id: 'classic-balanced', name: 'Classic Balanced', colors: { own: '#d94f4f', you: '#b76311', ghost: '#7a7a7a', catalogOwn: '#2f8f6a', catalogWatched: '#2f6cd6' } }, { id: 'ocean-ink', name: 'Ocean Ink', colors: { own: '#0f8fa8', you: '#5f3dc4', ghost: '#7f8c8d', catalogOwn: '#0a7a83', catalogWatched: '#b54708' } }, { id: 'forest-rose', name: 'Forest Rose', colors: { own: '#2f855a', you: '#b83280', ghost: '#6b7280', catalogOwn: '#1d7a55', catalogWatched: '#a16207' } }, { id: 'slate-citrus', name: 'Slate Citrus', colors: { own: '#2563eb', you: '#ca8a04', ghost: '#64748b', catalogOwn: '#0d9488', catalogWatched: '#dc2626' } }, { id: 'rust-teal', name: 'Rust Teal', colors: { own: '#b45309', you: '#2563eb', ghost: '#78716c', catalogOwn: '#0f766e', catalogWatched: '#be123c' } }, ]; if (batch > 0) { const baseHue = (batch * 47) % 360; const sat = profile.kind === 'dark' ? 72 : 66; const ownLight = profile.kind === 'dark' ? 64 : 42; const youLight = profile.kind === 'dark' ? 66 : 38; const ghostLight = profile.kind === 'dark' ? 68 : 48; const catalogLight = profile.kind === 'dark' ? 62 : 36; const generated = Array.from({ length: 5 }, (_, i) => { const h = baseHue + (i * 31); const accentShift = 96 + ((batch + i) % 3) * 24; return { id: `generated-${batch}-${i}`, name: `Generated ${batch + 1}.${i + 1}`, colors: { own: Settings.hslToHex(h, sat, ownLight), you: Settings.hslToHex(h + accentShift, sat - 4, youLight), ghost: Settings.hslToHex(h + 210, profile.kind === 'dark' ? 12 : 10, ghostLight), catalogOwn: Settings.hslToHex(h + 165, sat - 8, catalogLight), catalogWatched: Settings.hslToHex(h + 270, sat - 2, catalogLight + (profile.kind === 'dark' ? 3 : 2)), }, }; }); return { profile, palettes: generated, }; } return { profile, palettes: profile.kind === 'dark' ? dark : light, }; }, normalizeSavedHighlightPalette(raw) { if (!raw || typeof raw !== 'object') return null; const name = String(raw.name || '').trim(); if (!name) return null; const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : {}; const own = String(colors.own || '').trim().toLowerCase(); const you = String(colors.you || '').trim().toLowerCase(); const ghost = String(colors.ghost || '').trim().toLowerCase(); const catalogOwn = String(colors.catalogOwn || '').trim().toLowerCase(); const catalogWatched = String(colors.catalogWatched || '').trim().toLowerCase(); const isHex = (value) => /^#[0-9a-f]{6}$/i.test(value); if (![own, you, ghost, catalogOwn, catalogWatched].every(isHex)) return null; const markers = {}; const rawMarkers = raw.markers && typeof raw.markers === 'object' ? raw.markers : {}; for (const slot of ['own', 'you', 'ghost']) { const value = String(rawMarkers[slot] || '').trim().toLowerCase(); if (isHex(value)) markers[slot] = value; } return { name, colors: { own, you, ghost, catalogOwn, catalogWatched }, ...(raw.markers !== undefined ? { markers } : {}), }; }, savedHighlightPaletteList() { const raw = Conf['savedHighlightPalettes']; if (!Array.isArray(raw)) return []; const out = []; for (const item of raw) { const normalized = Settings.normalizeSavedHighlightPalette(item); if (!normalized) continue; out.push(normalized); } return out; }, setSavedHighlightPalettes(list) { const cleaned = list .map(item => Settings.normalizeSavedHighlightPalette(item)) .filter(Boolean); Conf['savedHighlightPalettes'] = cleaned; $.set('savedHighlightPalettes', cleaned); }, CUSTOM_SITE_THEME_PREFIX: 'custom:', isCustomSiteThemeValue(value) { return typeof value === 'string' && value.startsWith(Settings.CUSTOM_SITE_THEME_PREFIX); }, customSiteThemeName(value) { return Settings.isCustomSiteThemeValue(value) ? value.slice(Settings.CUSTOM_SITE_THEME_PREFIX.length) : ''; }, customSiteThemeList() { const raw = Conf['customSiteThemes']; if (!Array.isArray(raw)) return []; const out = []; for (const item of raw) { if (!item || typeof item !== 'object') continue; const name = String(item.name || '').trim(); const css = String(item.css || ''); if (!name) continue; out.push({ name, css }); } return out; }, findCustomSiteTheme(name) { return Settings.customSiteThemeList().find((t) => t.name === name) || null; }, nativeSiteThemes() { const themes = []; const seen = new Set(); const add = (value) => { const v = value?.trim(); if (!v || seen.has(v)) return; seen.add(v); themes.push(v); }; const nativeSelector = $.id('styleSelector'); if (nativeSelector?.options?.length) { for (const opt of nativeSelector.options) add(opt.value || opt.textContent || ''); return themes; } for (const link of $$('link[rel="alternate stylesheet"]', d.head)) { add(link.title); } return themes; }, nativeSiteThemeLabel(name) { const trimmed = (name || '').trim(); const withoutNew = trimmed.replace(/\s+new$/i, ''); return withoutNew || trimmed; }, populateSiteStylePicker(section, select) { if (!select) return; const picker = $('.styling-theme-picker', section); const toggleBtn = picker ? $('.styling-theme-toggle', picker) : null; const currentLabel = picker ? $('.styling-theme-current', picker) : null; const menu = picker ? $('.styling-theme-menu', picker) : null; const note = $('#styling-site-style-note', section); const nativeThemes = Settings.nativeSiteThemes(); const customThemes = Settings.customSiteThemeList(); while (select.firstChild) select.removeChild(select.firstChild); const seen = new Set(); const addOption = (value, text) => { if (!value || seen.has(value)) return; seen.add(value); $.add(select, $.el('option', { value, textContent: text || value })); }; for (const name of nativeThemes) addOption(name, Settings.nativeSiteThemeLabel(name)); for (const t of customThemes) addOption(`${Settings.CUSTOM_SITE_THEME_PREFIX}${t.name}`, `Custom: ${t.name}`); const noOptions = seen.size === 0; select.disabled = noOptions; if (toggleBtn) toggleBtn.disabled = noOptions; if (note) { if (noOptions) { note.hidden = false; note.textContent = 'Style options are only available on supported board pages.'; } else { note.hidden = true; } } const desired = Settings.styleConf('siteStyle'); if (desired && seen.has(desired)) { select.value = desired; } else if (!noOptions) { const current = $.id('styleSelector')?.value?.trim(); if (current && seen.has(current)) { select.value = current; } else if (select.selectedIndex < 0) { select.selectedIndex = 0; } } if (picker && menu && currentLabel) { Settings.renderSiteStyleMenu(picker, menu, select, currentLabel, customThemes, nativeThemes); } }, renderSiteStyleMenu(picker, menu, select, currentLabel, customThemes, nativeThemes) { while (menu.firstChild) menu.removeChild(menu.firstChild); const buildItem = (value, label, removable) => { const item = $.el('div', { className: 'styling-theme-item' }); item.setAttribute('role', 'option'); item.dataset.value = value; const labelEl = $.el('span', { className: 'styling-theme-item-label', textContent: label }); $.add(item, labelEl); if (removable) { const removeBtn = $.el('button', { type: 'button', className: 'styling-theme-item-remove', title: 'Remove custom theme', textContent: '✕', }); removeBtn.setAttribute('aria-label', `Remove ${label}`); $.on(removeBtn, 'click', (e) => { e.stopPropagation(); Settings.removeCustomSiteTheme(Settings.customSiteThemeName(value)); }); $.add(item, removeBtn); } $.on(item, 'click', () => { Settings.selectSiteStyleValue(select, value); Settings.closeSiteStyleMenu(picker); }); return item; }; for (const name of nativeThemes) { $.add(menu, buildItem(name, Settings.nativeSiteThemeLabel(name), false)); } if (customThemes.length && nativeThemes.length) { $.add(menu, $.el('div', { className: 'styling-theme-divider' })); } for (const t of customThemes) { $.add(menu, buildItem(`${Settings.CUSTOM_SITE_THEME_PREFIX}${t.name}`, `Custom: ${t.name}`, true)); } Settings.refreshSiteStyleCurrentLabel(currentLabel, select); }, refreshSiteStyleCurrentLabel(label, select) { if (!label) return; const value = select.value; if (!value) { label.textContent = '—'; return; } if (Settings.isCustomSiteThemeValue(value)) { label.textContent = `Custom: ${Settings.customSiteThemeName(value)}`; } else { label.textContent = Settings.nativeSiteThemeLabel(value); } }, selectSiteStyleValue(select, value) { if (!value || select.value === value) ; select.value = value; select.dispatchEvent(new Event('change', { bubbles: true })); }, openSiteStyleMenu(picker) { const menu = $('.styling-theme-menu', picker); const toggle = $('.styling-theme-toggle', picker); if (!menu || !toggle) return; picker.dataset.open = 'true'; menu.hidden = false; toggle.setAttribute('aria-expanded', 'true'); Settings.activeSiteStylePicker = picker; if (!Settings.siteStylePickerOutsideHandler) { Settings.siteStylePickerOutsideHandler = (e) => { const target = e.target; const active = Settings.activeSiteStylePicker; if (active && target && !active.contains(target)) { Settings.closeSiteStyleMenu(active); } }; d.addEventListener('mousedown', Settings.siteStylePickerOutsideHandler, true); } }, closeSiteStyleMenu(picker) { const menu = $('.styling-theme-menu', picker); const toggle = $('.styling-theme-toggle', picker); if (!menu || !toggle) return; picker.dataset.open = 'false'; menu.hidden = true; toggle.setAttribute('aria-expanded', 'false'); if (Settings.activeSiteStylePicker === picker) Settings.activeSiteStylePicker = null; }, refreshSiteStylePickers() { if (!Settings.dialog) return; const section = $('.section-styling', Settings.dialog); if (!section) return; const select = $('[name^="siteStyle"]', section); if (!select) return; Settings.populateSiteStylePicker(section, select); }, removeCustomSiteTheme(name) { if (!name) return; const list = Settings.customSiteThemeList().filter((t) => t.name !== name); Conf['customSiteThemes'] = list; $.set('customSiteThemes', list); const activeValue = `${Settings.CUSTOM_SITE_THEME_PREFIX}${name}`; const variants = ['sfw', 'nsfw']; let activeRemoved = false; for (const variant of variants) { const key = Settings.variantKey('siteStyle', variant); if (Conf[key] === activeValue) { const fallback = Settings.nativeSiteThemes()[0] || ''; Conf[key] = fallback; $.set(key, fallback); if (variant === Settings.getActiveVariant()) activeRemoved = true; } } if (activeRemoved) { const fallback = Settings.styleConf('siteStyle') || ''; if (Settings.dialog) { const select = $('#fourchanx-settings [name^="siteStyle"]'); if (select) { Settings.refreshSiteStylePickers(); select.value = fallback; select.dispatchEvent(new Event('change', { bubbles: true })); return; } } $.event('CustomSiteThemeChanged'); } Settings.refreshSiteStylePickers(); $.event('CustomSiteThemeChanged'); }, siteStyle() { const style = this.value; if (Settings.dialog) { const picker = $('.styling-theme-picker', Settings.dialog); if (picker) { const label = $('.styling-theme-current', picker); if (label) Settings.refreshSiteStyleCurrentLabel(label, this); } } const editingVariant = Settings.stylingEditingVariant; if (editingVariant && editingVariant !== Settings.getBoardVariant()) { return; } if (!style) { $.event('CustomSiteThemeChanged'); return; } if (Settings.isCustomSiteThemeValue(style)) { $.event('CustomSiteThemeChanged'); if (Conf['siteStyleHome']) { Settings.setSiteStyleHomeCookie(''); } return; } const nativeSelector = $.id('styleSelector'); if (nativeSelector?.options?.length) { const hasStyle = Array.from(nativeSelector.options).some(opt => opt.value === style); if (hasStyle && nativeSelector.value !== style) { nativeSelector.value = style; nativeSelector.dispatchEvent(new Event('change', { bubbles: true })); } } $.event('CustomSiteThemeChanged'); if (Conf['siteStyleHome']) { Settings.setSiteStyleHomeCookie(style); } }, bindSiteStylePicker(section) { const picker = $('.styling-theme-picker', section); if (!picker) return; const toggle = $('.styling-theme-toggle', picker); if (toggle) { $.on(toggle, 'click', (e) => { e.preventDefault(); if (toggle.disabled) return; if (picker.dataset.open === 'true') Settings.closeSiteStyleMenu(picker); else Settings.openSiteStyleMenu(picker); }); } }, siteStyleHome() { if (!this.checked) return; const activeStyle = Settings.styleConf('siteStyle'); const style = activeStyle || $('#fourchanx-settings [name^="siteStyle"]')?.value || ''; if (!style) return; if (!activeStyle) { const key = Settings.variantKey('siteStyle'); Conf[key] = style; $.set(key, style); } Settings.setSiteStyleHomeCookie(style); }, setSiteStyleHomeCookie(style) { if (!style) return; const domain = location.hostname.includes('4channel.org') ? '4channel.org' : '4chan.org'; const expires = 60 * 60 * 24 * 365; const cleanupDomains = [location.hostname, domain, `.${domain}`]; const past = 'Thu, 01 Jan 1970 00:00:00 GMT'; for (const key of ['ws_style', 'nws_style']) { d.cookie = `${key}=; Expires=${past}; Path=/`; for (const cookieDomain of cleanupDomains) { d.cookie = `${key}=; Expires=${past}; Path=/; Domain=${cookieDomain}`; } d.cookie = `${key}=${style}; Max-Age=${expires}; Path=/; Domain=${domain}`; } }, exportOptionOrder: [ 'General', 'Styling', 'Custom CSS', 'Interface', 'Threads & Posts', 'History', 'Watched Threads', 'Media', 'Posting', 'Filters', 'Keybinds', 'Advanced' ], exportSectionOrder: [ { name: 'General', option: 'General' }, { name: 'Styling', option: 'Styling', children: ['Custom CSS'] }, { name: 'Interface', option: 'Interface' }, { name: 'Threads & Posts', option: 'Threads & Posts', children: ['History', 'Watched Threads'] }, { name: 'Media', option: 'Media' }, { name: 'Posting', option: 'Posting' }, { name: 'Filters', option: 'Filters' }, { name: 'Keybinds', option: 'Keybinds' }, { name: 'Advanced', option: 'Advanced' } ], impExpPicker: null, exportOptionKeys() { const options = dict(); const keysIn = (category) => { const obj = Config.main[category] || dict(); const keys = []; for (const key in obj) { if (Array.isArray(obj[key])) keys.push(key); } return keys; }; const stylingOnlyKeys = [ 'Scrollbar Markers', 'Scrollbar Mark Own Posts', 'Scrollbar Mark Quotes You', 'Scrollbar Mark Ghost Posts', 'Scrollbar Mark Unread Line', 'Scrollbar Marker Position', 'Scrollbar Marker Hover Preview', 'Highlight Posts Quoting You', 'Highlight Own Posts', 'Highlight Ghost Posts' ]; options['General'] = [ 'JSON Index', `Use ${meta.name} Catalog`, 'Index Refresh Notifications', 'Open Threads in New Tab', 'External Catalog', '404 Redirect', 'Archive Report', 'Exempt Archives from Encryption', 'Show Updated Notifications', 'Disable Native Extension', 'Enable Native Flash Embedding', ...Object.keys(Config.Index) ]; options['Interface'] = [ 'Announcement Hiding', 'Follow Cursor', 'Catalog Links', 'Desktop Notifications', 'Posting Success Notifications', 'Keybinds', 'Comment Expansion', 'Thread Expansion', 'Index Navigation', 'Reply Navigation', 'Unique ID and Capcode Navigation', 'Normalize URL', 'Disable Autoplaying Sounds', 'boardnav', ...keysIn('Menu') ]; options['Threads & Posts'] = [ 'Custom Board Titles', 'Persistent Custom Board Titles', 'Color User IDs', 'Count Posts by ID', 'Remove Spoilers', 'Reveal Spoilers', 'Time Formatting', 'RelativeTime', 'File Info Formatting', 'Quote Backlinks', ...keysIn('Filtering').filter(key => !stylingOnlyKeys.includes(key)), ...keysIn('Monitoring').filter(key => !stylingOnlyKeys.includes(key)), ...keysIn('Quote Links').filter(key => !stylingOnlyKeys.includes(key)), ...Object.keys(Config.threadWatcher), 'Thread Watcher Thumbnail Size', 'Thread Watcher Thumbnail Preview Size', 'Thread Watcher Max Height', 'Thread Watcher Max Width', 'Thread Title', 'Unread Title Count', 'Interval', 'customCooldown' ]; options['History'] = [ 'lastReadPosts', 'yourPosts', 'hiddenThreads', 'hiddenPosts', 'hiddenPosterIds' ]; options['Watched Threads'] = [ 'watchedThreads', 'watcherBackup', 'watcherLastModified' ]; options['Media'] = [ ...keysIn('Images and Videos'), ...keysIn('Linkification'), 'sauces', 'selectedArchives' ]; options['Posting'] = [ ...keysIn('Posting and Captchas'), 'Comment Preview Position', 'Comment Preview Inline Behavior', 'Comment Preview Last Mode', 'Comment Preview Float Position', 'QR.personas' ]; options['Filters'] = Object.keys(Config.filter).concat(['easyFilters']); options['Styling'] = [ 'customCSSHome', 'siteStyle', 'siteStyleHome', 'customSiteThemes', 'savedHighlightPalettes', 'Enable Thread Highlights', 'Enable Catalog Highlights', 'textColorMode', 'Text Color', 'Link Text Color', 'Quote Text Color', 'Dead Link Text Color', 'Thread Highlight Edge Width', 'Highlight Own Edge Width', 'Highlight You Edge Width', 'Highlight Ghost Edge Width', 'Catalog Highlight Border Width', 'Catalog Highlight Own Border Width', 'Catalog Highlight Watched Border Width', 'Scroll Marker Match Highlights', 'Scroll Marker Own Match Highlight', 'Scroll Marker You Match Highlight', 'Scroll Marker Ghost Match Highlight', 'Catalog Highlight Own Posts', 'Catalog Highlight Watched Threads', 'Catalog Highlight Own Background', 'Catalog Highlight Watched Background', 'Highlight Own Color', 'Highlight You Color', 'Highlight Ghost Color', 'Catalog Highlight Own Color', 'Catalog Highlight Watched Color', 'Catalog Highlight Own Background', 'Catalog Highlight Watched Background', 'Catalog Highlight Own Text Mode', 'Catalog Highlight Watched Text Mode', 'Catalog Highlight Own Text Color', 'Catalog Highlight Own Subject Color', 'Catalog Highlight Own Link Color', 'Catalog Highlight Own Quote Color', 'Catalog Highlight Own Dead Link Color', 'Catalog Highlight Watched Text Color', 'Catalog Highlight Watched Subject Color', 'Catalog Highlight Watched Link Color', 'Catalog Highlight Watched Quote Color', 'Catalog Highlight Watched Dead Link Color', 'Highlight Own Text Auto', 'Highlight You Text Auto', 'Highlight Ghost Text Auto', 'Highlight Own Text Mode', 'Highlight You Text Mode', 'Highlight Ghost Text Mode', 'Highlight Own Text Color', 'Highlight Own Link Color', 'Highlight Own Quote Color', 'Highlight Own Dead Link Color', 'Highlight You Text Color', 'Highlight You Link Color', 'Highlight You Quote Color', 'Highlight You Dead Link Color', 'Highlight Ghost Text Color', 'Highlight Ghost Link Color', 'Highlight Ghost Quote Color', 'Highlight Ghost Dead Link Color', 'Highlight Own Opacity', 'Highlight You Opacity', 'Highlight Ghost Opacity', 'Highlight Own Background', 'Highlight You Background', 'Highlight Ghost Background', 'Catalog Highlight Own Opacity', 'Catalog Highlight Watched Opacity', 'Scroll Marker Own Color', 'Scroll Marker You Color', 'Scroll Marker Ghost Color', 'Scroll Marker Unread Color', 'Scroll Marker Own Opacity', 'Scroll Marker You Opacity', 'Scroll Marker Ghost Opacity', 'Scroll Marker Unread Opacity', ...stylingOnlyKeys, 'settings.customCSSEditorTheme', 'settings.customCSSEditorExpanded', 'settings.customCSSEditorBracketHighlight' ]; options['Custom CSS'] = ['Custom CSS', 'usercss']; options['Keybinds'] = Object.keys(Config.hotkeys); options['Advanced'] = [ 'archives', 'archiveLists', 'archiveAutoUpdate', 'lastarchivecheck', 'externalCatalogURLs', 'fourchanImageHost', 'captchaLanguage', 'time', 'timeLocale', 'backlink', 'pastedname', 'fileInfo', 'jsWhitelist', 'XEmbedder', 'fxtLang', 'fxtUrl', 'fxtMaxReplies', 'tfUrl', 'beepVolume', 'soundLibrary', 'boardSounds', 'defaultSoundId', 'sounds' ]; return options; }, optionForKey(key, keysByOption) { for (const name in keysByOption) { if (keysByOption[name].includes(key)) return name; } return 'General'; }, optionsPresentIn(conf) { const keysByOption = Settings.exportOptionKeys(); const present = dict(); for (const key in conf) { present[Settings.optionForKey(key, keysByOption)] = true; } return present; }, groupFilenameTag(groups) { if (!groups?.length || groups.length === Settings.exportOptionOrder.length) return ''; return '-' + groups.map(group => group.toLowerCase().replace(/[^a-z0-9]+/g, '-')).join('-'); }, export() { const Conf2 = dict(); $.extend(Conf2, Conf); $.get(Conf2, function (Conf2) { delete Conf2['boardConfig']; delete Conf2['settings.exportGroups']; $.get('settings.exportGroups', null, function (items) { const lastUsed = items['settings.exportGroups']; const defaultCheckedOptions = dict(); for (const name of Settings.exportOptionOrder) { defaultCheckedOptions[name] = lastUsed ? !!lastUsed[name] : true; } Settings.openImpExpPicker({ title: 'Export Settings', action: 'Export', conf: Conf2, defaultCheckedGroups: defaultCheckedOptions, onConfirm: (checkedOptions) => Settings.doExport(checkedOptions, Conf2) }); }); }); }, doExport(checkedOptions, conf) { const keysByOption = Settings.exportOptionKeys(); const out = dict(); for (const key in conf) { const option = Settings.optionForKey(key, keysByOption); if (checkedOptions[option]) out[key] = conf[key]; } const groups = Settings.exportOptionOrder.filter((name) => checkedOptions[name]); $.set('settings.exportGroups', checkedOptions); Settings.downloadExport({ version: g.VERSION, date: Date.now(), groups, Conf: out }); }, downloadExport(data) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const tag = Settings.groupFilenameTag(data.groups); const a = $.el('a', { download: `${meta.name} v${g.VERSION}-${data.date}${tag}.json`, href: url }); const p = $('.imp-exp-result', Settings.dialog); $.rmAll(p); $.add(p, a); a.click(); }, import() { $('input[type=file]', this.parentNode).click(); }, onImport() { if (this.type !== 'file') { return; } let file; if (!(file = this.files[0])) { return; } this.value = null; const output = $('.imp-exp-result', Settings.dialog); const reader = new FileReader(); reader.onload = function (e) { try { let data = dict.json(e.target.result); if (!data?.Conf && (data?.watchedThreads || data?.watcherBackup)) { data = { version: data.version || g.VERSION, date: data.date || Date.now(), Conf: data }; } if (!data?.Conf) { output.textContent = 'Import failed: file is not a valid settings export.'; return; } Settings.openImpExpPicker({ title: 'Import Settings', action: 'Import', conf: data.Conf, onConfirm: (checkedOptions) => Settings.doImport(data, checkedOptions) }); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); output.textContent = 'Import failed due to an error.'; c.error(err.stack); } }; reader.readAsText(file); }, doImport(data, checkedGroups) { const output = $('.imp-exp-result', Settings.dialog); if (!Object.keys(checkedGroups).length) { output.textContent = 'Import aborted.'; return; } const keysByGroup = Settings.exportOptionKeys(); const presentGroups = Settings.optionsPresentIn(data.Conf); const allSelected = !!checkedGroups['General'] && Object.keys(presentGroups).every(name => checkedGroups[name]); if (allSelected && !confirm('Your current settings will be entirely overwritten, are you sure?')) { output.textContent = 'Import aborted.'; return; } const selected = dict(); $.extend(selected, data); selected.Conf = dict(); for (const key in data.Conf) { const group = Settings.optionForKey(key, keysByGroup); if (checkedGroups[group]) { selected.Conf[key] = data.Conf[key]; } } if (selected.version && selected.version !== g.VERSION) { Settings.upgrade(selected.Conf, selected.version); const upgradedAndSelected = dict(); for (const key in selected.Conf) { const group = Settings.optionForKey(key, keysByGroup); if (checkedGroups[group]) upgradedAndSelected[key] = selected.Conf[key]; } selected.Conf = upgradedAndSelected; } if (allSelected) { Settings.loadSettings(selected, function (err) { if (err) { output.textContent = 'Import failed due to an error.'; } else if (confirm('Import successful. Reload now?')) { window.location.reload(); } }); return; } $.set(selected.Conf, function (err) { if (err) { output.textContent = 'Import failed due to an error.'; } else if (confirm('Import successful. Reload now?')) { window.location.reload(); } }); }, openImpExpPicker({ title, action, conf, defaultCheckedGroups, onConfirm }) { if (!Settings.dialog) return; Settings.closeImpExpPicker(); const presentGroups = Settings.optionsPresentIn(conf); const overlay = $.el('div', { className: 'imp-exp-picker-overlay' }); const picker = $.el('div', { className: 'imp-exp-picker dialog' }); $.add(overlay, picker); $.add(picker, $.el('h3', { className: 'imp-exp-picker-title', textContent: title })); const list = $.el('div', { className: 'imp-exp-picker-list' }); const checkboxes = dict(); const syncSectionStates = []; let syncToggle = () => { }; let hasAny = false; for (const sectionInfo of Settings.exportSectionOrder) { const hasSectionOption = !!presentGroups[sectionInfo.option]; const childOptions = (sectionInfo.children || []).filter((name) => presentGroups[name]); const sectionOptions = [ ...(hasSectionOption ? [sectionInfo.option] : []), ...childOptions ]; if (!sectionOptions.length) continue; hasAny = true; const sectionRow = $.el('div', { className: 'imp-exp-picker-section' }); const sectionTitleRow = $.el('div', { className: 'imp-exp-picker-row imp-exp-picker-section-title' }); const sectionInput = $.el('input', { type: 'checkbox', checked: sectionOptions.every(name => !defaultCheckedGroups || defaultCheckedGroups[name] !== false), autocomplete: 'off' }); const sectionLabel = $.el('label'); $.add(sectionLabel, sectionInput); $.add(sectionLabel, $.tn(` ${sectionInfo.name}`)); $.add(sectionTitleRow, sectionLabel); $.add(sectionRow, sectionTitleRow); if (hasSectionOption) { checkboxes[sectionInfo.option] = sectionInput; } if (childOptions.length) { const children = $.el('div', { className: 'imp-exp-picker-children' }); for (const name of childOptions) { const input = $.el('input', { type: 'checkbox', checked: defaultCheckedGroups ? (defaultCheckedGroups[name] !== false) : true, name: 'fcx-impexp-group', autocomplete: 'off' }); const label = $.el('label'); $.add(label, input); $.add(label, $.tn(` ${name}`)); checkboxes[name] = input; const row = $.el('div', { className: 'imp-exp-picker-row imp-exp-picker-child' }); $.add(row, label); $.add(children, row); } $.add(sectionRow, children); } const syncSection = () => { const inputs = sectionOptions.map(name => checkboxes[name]).filter(Boolean); const checked = inputs.filter(input => input.checked).length; sectionInput.checked = checked === inputs.length; sectionInput.indeterminate = checked > 0 && checked < inputs.length; }; syncSectionStates.push(syncSection); $.on(sectionInput, 'change', () => { const target = sectionInput.checked; sectionInput.indeterminate = false; for (const name of sectionOptions) { checkboxes[name].checked = target; } syncToggle(); }); for (const name of sectionOptions) { if (name === sectionInfo.option) continue; $.on(checkboxes[name], 'change', syncSection); } syncSection(); $.add(list, sectionRow); } if (hasAny) { const toggleRow = $.el('div', { className: 'imp-exp-picker-row imp-exp-picker-toggle' }); const toggleInput = $.el('input', { type: 'checkbox', checked: true, name: 'fcx-impexp-toggle', autocomplete: 'off' }); const toggleLabel = $.el('label'); $.add(toggleLabel, toggleInput); $.add(toggleLabel, $.tn(' Check all')); $.add(toggleRow, toggleLabel); $.prepend(list, toggleRow); syncToggle = () => { const inputs = Object.values(checkboxes); const checked = inputs.filter(input => input.checked).length; toggleInput.checked = checked === inputs.length; toggleInput.indeterminate = checked > 0 && checked < inputs.length; }; $.on(toggleInput, 'change', () => { const target = toggleInput.checked; toggleInput.indeterminate = false; for (const input of Object.values(checkboxes)) { input.checked = target; } for (const syncSection of syncSectionStates) syncSection(); }); for (const input of Object.values(checkboxes)) { $.on(input, 'change', syncToggle); } syncToggle(); $.add(picker, list); } else { $.add(picker, $.el('p', { className: 'imp-exp-picker-empty', textContent: 'No recognizable settings groups found.' })); } const buttons = $.el('div', { className: 'imp-exp-picker-buttons' }); const cancelBtn = $.el('button', { type: 'button', textContent: 'Cancel' }); const confirmBtn = $.el('button', { type: 'button', textContent: action, disabled: !hasAny }); $.add(buttons, [cancelBtn, confirmBtn]); $.add(picker, buttons); const close = () => Settings.closeImpExpPicker(); $.on(cancelBtn, 'click', close); $.on(overlay, 'click', e => { if (e.target === overlay) close(); }); $.on(confirmBtn, 'click', () => { const checked = dict(); for (const [name, input] of Object.entries(checkboxes)) { if (input.checked) checked[name] = true; } Settings.closeImpExpPicker(); onConfirm(checked); }); $.add(Settings.dialog, overlay); Settings.impExpPicker = overlay; }, closeImpExpPicker() { if (!Settings.impExpPicker) return; $.rm(Settings.impExpPicker); Settings.impExpPicker = null; }, upgrade(data, version) { let corrupted, key, val; const changes = dict(); const set = (key, value) => data[key] = (changes[key] = value); if (corrupted = (version[0] === '"')) { try { version = JSON.parse(version); } catch (error) { } } const compareString = version.replace(/^XT /i, '').replace(/\d+/g, x => x.padStart(5, '0')); if (corrupted) { for (key in data) { val = data[key]; if (typeof val === 'string') { try { var val2 = JSON.parse(val); set(key, val2); } catch (error1) { } } } } if (compareString < '00001.00014.00016.00001') { if (data['archiveLists'] != null) { set('archiveLists', data['archiveLists'].replace('https://mayhemydg.github.io/archives.json/archives.json', 'https://nstepien.github.io/archives.json/archives.json')); } } if (compareString < '00001.00014.00016.00007') { if (data['sauces'] != null) { set('sauces', data['sauces'].replace(/https:\/\/www\.deviantart\.com\/gallery\/#\/d%\$1%\$2;regexp:\/\^\\w\+_by_\\w\+\[_-\]d\(\[\\da-z\]\{6\}\)\\b\|\^d\(\[\\da-z\]\{6\}\)-\[\\da-z\]\{8\}-\//g, 'javascript:void(open("https://www.deviantart.com/"+%$1.replace(/_/g,"-")+"/art/"+parseInt(%$2,36)));regexp:/^\\w+_by_(\\w+)[_-]d([\\da-z]{6})\\b/').replace(/\/\/imgops\.com\/%URL/g, '//imgops.com/start?url=%URL')); } } if (compareString < '00001.00014.00017.00002') { if (data['jsWhitelist'] != null && !data['jsWhitelist'].includes('hcaptcha.com')) { set('jsWhitelist', data['jsWhitelist'] + '\n\nhttps://hcaptcha.com\nhttps://*.hcaptcha.com'); } } if (compareString < '00001.00014.00020.00004') { if (data['archiveLists'] != null) { set('archiveLists', data['archiveLists'].replace('https://nstepien.github.io/archives.json/archives.json', 'https://4chenz.github.io/archives.json/archives.json')); } } if (compareString < '00001.00014.00022.00003') { if (data['sauces']) { set('sauces', data['sauces'].replace(/^#?\s*https:\/\/www\.google\.com\/searchbyimage\?image_url=%(IMG|T?URL)&safe=off(?=$|;)/mg, 'https://www.google.com/searchbyimage?sbisrc=4chanx&image_url=%$1&safe=off')); if (compareString === '00001.00014.00022.00002' && !/\bsbisrc=/.test(data['sauces'])) { set('sauces', data['sauces'].replace(/^#?\s*https:\/\/lens\.google\.com\/uploadbyurl\?url=%(IMG|T?URL)(?=$|;)/m, 'https://www.google.com/searchbyimage?sbisrc=4chanx&image_url=%$1&safe=off')); } } } if (compareString < '00002.00003.00001.00000') { if (data['boardnav']) { set('boardnav', data['boardnav'].replace('[external-text:"FAQ","4chan-neXT"]', `[external-text:"FAQ","${meta.faq}"]`)); } } if (compareString < '00002.00003.00006.00000' && data['RelativeTime'] === undefined && data['Relative Post Dates'] !== undefined) { set('RelativeTime', data['Relative Post Dates'] ? (data['Relative Date Title'] ? 'Hover' : 'Show') : 'No'); } if (data['Spoiler Mode'] === undefined && (data['Remove Spoilers'] !== undefined || data['Reveal Spoilers'] !== undefined)) { set('Spoiler Mode', data['Remove Spoilers'] ? 'remove' : (data['Reveal Spoilers'] ? 'reveal' : 'default')); } if (data['QR Drafts'] === undefined && data['Remember QR State'] !== undefined) { set('QR Drafts', data['Remember QR State']); } if (data['Dump List Remove File First'] === undefined) { if (data['QR Remove Button Clears File First'] !== undefined) { set('Dump List Remove File First', data['QR Remove Button Clears File First']); } else if (data['QR Thumbnail Remove File First'] !== undefined) { set('Dump List Remove File First', data['QR Thumbnail Remove File First']); } } if (compareString === '00002.00009.00000.00000') { set('XEmbedder', data['Embed Tweets inline with fxTwitter'] ? 'fxt' : 'tf'); set('fxtMaxReplies', data['Resolve Tweet Replies'] ? (data['Resolve all Tweet Replies'] ? 100 : 1) : 0); set('fxtLang', data['Translate non-English Tweets to English'] ? 'en' : ''); } for (const k of styleVariantKeys) { const legacy = data[k]; if (legacy === undefined) continue; if (data[`${k} SFW`] === undefined) set(`${k} SFW`, legacy); if (data[`${k} NSFW`] === undefined) set(`${k} NSFW`, legacy); } for (const variant of ['SFW', 'NSFW']) { const legacyWidth = data[`Highlight Edge Width ${variant}`] ?? data['Highlight Edge Width']; if (legacyWidth === undefined) continue; if (data[`Thread Highlight Edge Width ${variant}`] === undefined) set(`Thread Highlight Edge Width ${variant}`, legacyWidth); if (data[`Catalog Highlight Border Width ${variant}`] === undefined) set(`Catalog Highlight Border Width ${variant}`, legacyWidth); } for (const variant of ['SFW', 'NSFW']) { const threadShared = data[`Thread Highlight Edge Width ${variant}`] ?? data['Thread Highlight Edge Width'] ?? 3; for (const k of ['Highlight Own Edge Width', 'Highlight You Edge Width', 'Highlight Ghost Edge Width']) { if (data[`${k} ${variant}`] === undefined) set(`${k} ${variant}`, threadShared); } const catalogShared = data[`Catalog Highlight Border Width ${variant}`] ?? data['Catalog Highlight Border Width'] ?? 3; for (const k of ['Catalog Highlight Own Border Width', 'Catalog Highlight Watched Border Width']) { if (data[`${k} ${variant}`] === undefined) set(`${k} ${variant}`, catalogShared); } } for (const [oldKey, newKey] of [ ['Highlight Own Edge Only', 'Highlight Own Background'], ['Highlight You Edge Only', 'Highlight You Background'], ['Highlight Ghost Edge Only', 'Highlight Ghost Background'], ]) { if (data[newKey] === undefined) { set(newKey, data[oldKey] !== undefined ? !data[oldKey] : false); } } for (const [oldKey, newKey] of [ ['Catalog Highlight Own Border Only', 'Catalog Highlight Own Background'], ['Catalog Highlight Watched Border Only', 'Catalog Highlight Watched Background'], ]) { for (const variant of ['SFW', 'NSFW']) { const old = data[`${oldKey} ${variant}`] ?? data[oldKey]; if (old !== undefined && data[`${newKey} ${variant}`] === undefined) { set(`${newKey} ${variant}`, !old); } } } const highlightTextTypes = [ ['Highlight Own Text Mode', ['Highlight Own Text Color', 'Highlight Own Link Color', 'Highlight Own Quote Color', 'Highlight Own Dead Link Color']], ['Highlight You Text Mode', ['Highlight You Text Color', 'Highlight You Link Color', 'Highlight You Quote Color', 'Highlight You Dead Link Color']], ['Highlight Ghost Text Mode', ['Highlight Ghost Text Color', 'Highlight Ghost Link Color', 'Highlight Ghost Quote Color', 'Highlight Ghost Dead Link Color']], ['Catalog Highlight Own Text Mode', ['Catalog Highlight Own Text Color', 'Catalog Highlight Own Subject Color', 'Catalog Highlight Own Link Color', 'Catalog Highlight Own Quote Color', 'Catalog Highlight Own Dead Link Color']], ['Catalog Highlight Watched Text Mode', ['Catalog Highlight Watched Text Color', 'Catalog Highlight Watched Subject Color', 'Catalog Highlight Watched Link Color', 'Catalog Highlight Watched Quote Color', 'Catalog Highlight Watched Dead Link Color']], ]; for (const [legacyKey, modeBase] of [ ['Highlight Own Text Auto', 'Highlight Own Text Mode'], ['Highlight You Text Auto', 'Highlight You Text Mode'], ['Highlight Ghost Text Auto', 'Highlight Ghost Text Mode'], ]) { if (data[legacyKey] !== undefined && data[modeBase] === undefined) { set(modeBase, data[legacyKey] ? 'auto' : 'manual'); } } if (data['Scroll Marker Match Highlights'] !== undefined) { for (const key of [ 'Scroll Marker Own Match Highlight', 'Scroll Marker You Match Highlight', 'Scroll Marker Ghost Match Highlight', ]) { if (data[key] === undefined) set(key, !!data['Scroll Marker Match Highlights']); } } for (const variant of ['SFW', 'NSFW']) { for (const [modeBase, colorBases] of highlightTextTypes) { const modeKey = `${modeBase} ${variant}`; if (data[modeKey] !== undefined) continue; const hasManual = colorBases.some(c => { const v = data[`${c} ${variant}`]; return typeof v === 'string' && v !== ''; }); if (hasManual) set(modeKey, 'manual'); } } return changes; }, loadSettings(data, cb) { if (data.version !== g.VERSION) { Settings.upgrade(data.Conf, data.version); } $.clear(function (err) { if (err) { return cb(err); } $.set(data.Conf, cb); }); }, reset() { if (confirm('Your current settings will be entirely wiped, are you sure?')) { $.clear(function (err) { if (err) { $('.imp-exp-result').textContent = 'Import failed due to an error.'; } else if (confirm('Reset successful. Reload now?')) { window.location.reload(); } }); } }, filtersPreviewState: null, forcedFiltersMode: null, forcedFilterType: null, easyFiltersFlush: null, filter(section) { const simplePanel = $.el('div'); const advancedPanel = $.el('div'); const previewState = { panel: null, simpleContainer: null, advancedType: null, advancedTextarea: null, }; Settings.filtersPreviewState = previewState; Settings.advancedFilter(advancedPanel, previewState); Settings.easyFilters(simplePanel, previewState); const details = $.el('details', { open: true }, { innerHTML: 'Filtering Rules' }); const subnav = $.el('div', { className: 'settings-subnav' }); const simpleModePanel = $.el('div', { className: 'filter-mode-panel filter-mode-simple' }); const advancedModePanel = $.el('div', { className: 'filter-mode-panel filter-mode-advanced', hidden: true }); $.add(simpleModePanel, simplePanel); $.add(advancedModePanel, advancedPanel); $.add(details, [subnav, simpleModePanel, advancedModePanel]); $.add(section, details); const panelByMode = { simple: simpleModePanel, advanced: advancedModePanel, }; const tabByMode = {}; const applyForcedFilterType = () => { if (!Settings.forcedFilterType) return; const select = $('select[name=filter]', advancedPanel); if (!select) return; const forceType = Settings.forcedFilterType; Settings.forcedFilterType = null; select.value = forceType; Settings.selectFilter.call(select); }; const setMode = (mode) => { if (!panelByMode[mode]) mode = 'simple'; for (const key in panelByMode) { panelByMode[key].hidden = key !== mode; if (tabByMode[key]) { tabByMode[key].classList.toggle('settings-subnav-tab-selected', key === mode); tabByMode[key].setAttribute('aria-pressed', key === mode ? 'true' : 'false'); } } if (mode === 'advanced') applyForcedFilterType(); $.set('settings.filtersMode', mode); Settings.refreshCombinedFilterPreview(previewState); }; const addModeTab = (mode, label) => { const tab = $.el('button', { type: 'button', className: 'settings-subnav-tab', textContent: label, }); tabByMode[mode] = tab; $.on(tab, 'click', () => setMode(mode)); $.add(subnav, tab); }; addModeTab('simple', 'Simple'); addModeTab('advanced', 'Advanced'); $.get('settings.filtersMode', 'simple', (item) => { let mode = Settings.forcedFiltersMode || item['settings.filtersMode']; Settings.forcedFiltersMode = null; if (!['simple', 'advanced'].includes(mode)) mode = 'simple'; if (mode === 'advanced') details.open = true; setMode(mode); }); }, advancedFilter(section, previewState) { $.extend(section, { innerHTML: FilterSelectPage }); const select = $('select', section); select.filterPreviewState = previewState; $.on(select, 'change', Settings.selectFilter); Settings.selectFilter.call(select); }, selectFilter() { let name; const div = this.nextElementSibling; const previewState = this.filterPreviewState; if ((name = this.value) !== 'guide') { if (!$.hasOwn(Config.filter, name)) { return; } $.rmAll(div); const ta = $.el('textarea', { name, className: 'field', spellcheck: false }); $.on(ta, 'change', $.cb.value); $.get(name, Conf[name], function (item) { ta.value = item[name]; $.add(div, ta); Settings.prepareAutosaveTextarea(ta); Settings.addFilterStats(name, ta, div, previewState); }); return; } const filterTypes = Object.keys(Config.filter) .filter(x => x !== 'general') .join(',\u200B'); $.extend(div, { innerHTML: FilterGuidePage }); $('#filterTypes', div).textContent = `type:\u200B${filterTypes};`; $('.warning', div).hidden = Conf['Filter']; }, easyFilterTypes: [ ['General', 'general'], ['Post Number', 'postID'], ['Name', 'name'], ['Unique ID', 'uniqueID'], ['Tripcode', 'tripcode'], ['Capcode', 'capcode'], ['Pass Date', 'pass'], ['Email', 'email'], ['Subject', 'subject'], ['Comment', 'comment'], ['Flag', 'flag'], ['Filename', 'filename'], ['Dimensions', 'dimensions'], ['Filesize', 'filesize'], ['Image MD5', 'MD5'], ], easyFilters(section, previewState) { $.extend(section, { innerHTML: SimpleFiltersPage }); const container = $('.easy-filters-list', section); const addButton = $('.easy-filter-add', section); const status = $('.easy-filter-status', section); if (previewState) previewState.simpleContainer = container; let resizeObserver = null; let lastAutoWidth = -1; const dialogWidth = () => { const dialog = container.closest('#fourchanx-settings'); return (dialog || container).getBoundingClientRect().width; }; const inlineRowsWrap = () => { container.className = 'easy-filters-list layout-list'; for (const tile of $$('.easy-filter-tile', container)) { let min = Infinity, max = -Infinity; for (const item of $$('.easy-filter-on, .easy-filter-remove, .easy-filter-field', tile)) { const r = item.getBoundingClientRect(); if (!r.height) continue; const center = r.top + r.height / 2; if (center < min) min = center; if (center > max) max = center; } if (max - min > 6) return true; } return false; }; const resolveAuto = () => { lastAutoWidth = dialogWidth(); container.className = `easy-filters-list ${inlineRowsWrap() ? 'layout-grid' : 'layout-list'}`; }; const setLayout = (mode) => { if (!['auto', 'grid', 'list'].includes(mode)) mode = 'auto'; for (const btn of $$('.easy-filter-layout-btn', section)) { btn.classList.toggle('selected', btn.dataset.layout === mode); } $.set('settings.easyFiltersLayout', mode); if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; } if (mode === 'auto') { resolveAuto(); if (typeof ResizeObserver !== 'undefined') { resizeObserver = new ResizeObserver(() => { if (Math.round(dialogWidth()) !== Math.round(lastAutoWidth)) resolveAuto(); }); resizeObserver.observe(container.closest('#fourchanx-settings') || container); } } else { container.className = `easy-filters-list layout-${mode}`; } }; for (const btn of $$('.easy-filter-layout-btn', section)) { $.on(btn, 'click', () => setLayout(btn.dataset.layout || 'auto')); } $.get('settings.easyFiltersLayout', 'auto', (item) => setLayout(item['settings.easyFiltersLayout'])); const save = () => { const rules = Settings.collectEasyFilters(container); const serialized = JSON.stringify(rules); $.set('easyFilters', serialized); Conf['easyFilters'] = serialized; status.textContent = `Saved ${rules.length} rule${rules.length === 1 ? '' : 's'}.`; Settings.refreshCombinedFilterPreview(previewState); }; let saveTimer = null; const markDirty = () => { status.textContent = 'Saving…'; Settings.refreshCombinedFilterPreview(previewState); clearTimeout(saveTimer); saveTimer = setTimeout(() => { saveTimer = null; save(); }, 500); }; Settings.easyFiltersFlush = () => { if (saveTimer == null) return; clearTimeout(saveTimer); saveTimer = null; save(); }; const addRow = (rule = {}) => { const row = Settings.easyFilterRow(rule, markDirty); $.add(container, row); return row; }; const rules = Settings.parseEasyFilters(); if (rules.length) { for (const rule of rules) addRow(rule); } else { addRow({ enabled: true, hide: true, type: 'tripcode' }); } $.on(addButton, 'click', () => { const row = addRow({ enabled: true, hide: true, type: 'tripcode' }); const patternInput = $('.easy-filter-pattern', row); if (patternInput) { patternInput.focus(); patternInput.select(); } markDirty(); }); status.textContent = `Loaded ${rules.length} rule${rules.length === 1 ? '' : 's'}.`; Settings.refreshCombinedFilterPreview(previewState); }, parseEasyFilters() { return Filter.parseEasyFilterRules(Conf['easyFilters']); }, easyFilterRow(rule, markDirty) { const tr = $.el('div', { className: 'easy-filter-tile', innerHTML: `
    `, }); const typeSelect = $('.easy-filter-type', tr); $.add(typeSelect, $.el('option', { textContent: 'Filter type', value: '', disabled: true })); for (const [label, value] of Settings.easyFilterTypes) { $.add(typeSelect, $.el('option', { textContent: label, value })); } const enabledInput = $('.easy-filter-enabled', tr); const patternInput = $('.easy-filter-pattern', tr); const boardsInput = $('.easy-filter-boards', tr); const colorOnInput = $('.easy-filter-color-on', tr); const colorInput = $('.easy-filter-color', tr); const classInput = $('.easy-filter-class', tr); const autoInput = $('.easy-filter-auto', tr); const hideInput = $('.easy-filter-hide', tr); const overrideInput = $('.easy-filter-override', tr); const removeButton = $('.easy-filter-remove', tr); enabledInput.checked = rule.enabled != null ? !!rule.enabled : true; patternInput.value = rule.pattern || ''; boardsInput.value = rule.boards || ''; typeSelect.value = (rule.type in Config.filter) ? rule.type : 'general'; const hexColor = /^#?([0-9a-f]{6})$/i.exec((rule.color || '').trim()); colorInput.value = hexColor ? `#${hexColor[1].toLowerCase()}` : '#dd0000'; colorOnInput.checked = !!rule.colorOn; classInput.value = rule.hlClass || ''; autoInput.checked = !!rule.auto; hideInput.checked = rule.hide != null ? !!rule.hide : true; overrideInput.checked = !!rule.override; const syncHighlightControls = () => { const hidden = hideInput.checked; overrideInput.disabled = hidden; if (hidden) overrideInput.checked = false; colorOnInput.disabled = hidden; colorInput.disabled = hidden || !colorOnInput.checked; classInput.disabled = hidden; }; syncHighlightControls(); $.on(hideInput, 'change', syncHighlightControls); $.on(colorOnInput, 'change', syncHighlightControls); const syncEnabledState = () => { tr.classList.toggle('easy-filter-off', !enabledInput.checked); }; syncEnabledState(); $.on(enabledInput, 'change', syncEnabledState); for (const input of $$('input, select', tr)) { $.on(input, 'change', markDirty); if (input.type === 'text') { $.on(input, 'input', markDirty); } } $.on(removeButton, 'click', () => { $.rm(tr); markDirty(); }); return tr; }, collectEasyFilters(container) { const rules = []; for (const tr of $$('.easy-filter-tile', container)) { const pattern = $('.easy-filter-pattern', tr).value.trim(); if (!pattern) continue; const type = $('.easy-filter-type', tr).value; const hide = $('.easy-filter-hide', tr).checked; rules.push({ enabled: $('.easy-filter-enabled', tr).checked, pattern, boards: $('.easy-filter-boards', tr).value.trim(), type: (type in Config.filter) ? type : 'general', color: $('.easy-filter-color', tr).value.trim(), colorOn: $('.easy-filter-color-on', tr).checked, hlClass: $('.easy-filter-class', tr).value.trim().replace(/^\.+/, '').replace(/[^\w-]/g, ''), auto: $('.easy-filter-auto', tr).checked, hide, override: !hide && $('.easy-filter-override', tr).checked, }); } return rules; }, easyFilterRuleFromRow(tr) { const pattern = $('.easy-filter-pattern', tr).value.trim(); if (!pattern) return null; const type = $('.easy-filter-type', tr).value; const hide = $('.easy-filter-hide', tr).checked; return { enabled: $('.easy-filter-enabled', tr).checked, pattern, boards: $('.easy-filter-boards', tr).value.trim(), type: (type in Config.filter) ? type : 'general', color: $('.easy-filter-color', tr).value.trim(), colorOn: $('.easy-filter-color-on', tr).checked, hlClass: $('.easy-filter-class', tr).value.trim().replace(/^\.+/, '').replace(/[^\w-]/g, ''), auto: $('.easy-filter-auto', tr).checked, hide, override: !hide && $('.easy-filter-override', tr).checked, }; }, easyFilterRuleToLine(rule) { return Filter.easyRuleToLine(rule); }, addFilterStats(type, textarea, container, previewState) { if (previewState) { previewState.advancedType = type; previewState.advancedTextarea = textarea; const refresh = () => Settings.refreshCombinedFilterPreview(previewState); $.on(textarea, 'input', refresh); $.on(textarea, 'change', refresh); refresh(); return; } }, refreshCombinedFilterPreview(previewStateArg) { const previewState = arguments.length === 0 ? Settings.filtersPreviewState : previewStateArg; const panel = previewState?.panel; if (!panel) return; $.rmAll(panel); if (!previewState.simpleContainer && !previewState.advancedTextarea) { $.add(panel, $.el('div', { className: 'filter-stats-empty', textContent: 'No filters loaded yet.', })); return; } if (previewState.simpleContainer) { const simpleGroup = $.el('div', { className: 'filter-preview-group' }); $.add(simpleGroup, $.el('div', { className: 'filter-preview-heading', textContent: 'Simple Filters' })); const simplePanel = $.el('div', { className: 'filter-stats' }); $.add(simpleGroup, simplePanel); Settings.renderEasyFilterPreview(previewState.simpleContainer, simplePanel); $.add(panel, simpleGroup); } if (previewState.advancedTextarea) { const advancedGroup = $.el('div', { className: 'filter-preview-group' }); const advancedLabel = previewState.advancedType ? `Advanced Filters (${previewState.advancedType})` : 'Advanced Filters'; $.add(advancedGroup, $.el('div', { className: 'filter-preview-heading', textContent: advancedLabel })); const advancedPanel = $.el('div', { className: 'filter-stats' }); $.add(advancedGroup, advancedPanel); Settings.renderFilterStats(previewState.advancedType, previewState.advancedTextarea, advancedPanel); $.add(panel, advancedGroup); } }, renderEasyFilterPreview(container, panel) { $.rmAll(panel); if (!g.BOARD?.threads || (g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'catalog')) { $.add(panel, $.el('div', { className: 'filter-stats-empty', textContent: 'Thread match preview is available on board and catalog pages.', })); return; } const entries = Settings.filterPreviewEntries(); if (!entries.length) { $.add(panel, $.el('div', { className: 'filter-stats-empty', textContent: 'No loaded thread data to preview.', })); return; } const stats = []; let totalMatches = 0; let totalHidden = 0; let activeRules = 0; let rowNo = 0; for (const tr of $$('.easy-filter-tile', container)) { rowNo++; const rule = Settings.easyFilterRuleFromRow(tr); if (!rule) continue; const lineText = Settings.easyFilterRuleToLine(rule); if (!lineText) { stats.push({ rowNo, rule, disabled: true }); continue; } activeRules++; const parsed = Settings.parseFilterPreviewLine('general', lineText); if (parsed?.invalid) { stats.push({ rowNo, rule, invalid: parsed.invalid }); continue; } if (parsed?.skip) continue; const result = Settings.collectFilterPreviewMatches(parsed, entries); totalMatches += result.matches.length; totalHidden += result.hiddenThreadCount; stats.push({ rowNo, rule, matches: result.matches, hiddenThreadCount: result.hiddenThreadCount }); } if (!stats.length) { $.add(panel, $.el('div', { className: 'filter-stats-empty', textContent: 'Add a pattern to preview thread matches.', })); return; } $.add(panel, $.el('div', { className: 'filter-stats-summary', textContent: `${activeRules} active rule${activeRules === 1 ? '' : 's'}, ${totalMatches} matching thread${totalMatches === 1 ? '' : 's'}, ${totalHidden} hidden thread${totalHidden === 1 ? '' : 's'}.`, })); const maxThreads = 50; for (const stat of stats) { const row = $.el('div', { className: 'filter-stat-row' }); let ruleText = `${stat.rule.type}: ${stat.rule.pattern}`; if (ruleText.length > 120) ruleText = `${ruleText.slice(0, 117)}...`; if (stat.disabled) { $.add(row, [ $.el('span', { className: 'filter-stat-count', textContent: `Rule ${stat.rowNo}: disabled` }), $.tn(' '), $.el('code', { textContent: ruleText }), ]); $.add(panel, row); continue; } if (stat.invalid) { $.add(row, $.el('div', { className: 'filter-stat-invalid', textContent: `Rule ${stat.rowNo}: invalid regex (${stat.invalid})`, })); $.add(panel, row); continue; } const matchCount = stat.matches.length; let summaryText = `Rule ${stat.rowNo}: ${matchCount} matching thread${matchCount === 1 ? '' : 's'}`; if (stat.hiddenThreadCount) summaryText += `, ${stat.hiddenThreadCount} hidden`; if (matchCount) { const details = $.el('details', { className: 'filter-stat' }); const summaryEl = $.el('summary'); $.add(summaryEl, [ $.el('span', { className: 'filter-stat-count', textContent: summaryText }), $.tn(' '), $.el('code', { textContent: ruleText }), ]); $.add(details, summaryEl); const list = $.el('ul', { className: 'filter-stat-threads' }); stat.matches.slice(0, maxThreads).forEach((m) => { const { entry, hidesThread } = m; const { href, text } = Settings.filterPreviewThreadLink(entry); const li = $.el('li'); $.add(li, $.el('a', { href, textContent: text })); if (hidesThread) $.add(li, $.tn(' (hidden)')); $.add(list, li); }); if (stat.matches.length > maxThreads) { $.add(list, $.el('li', { className: 'filter-stat-more', textContent: `...and ${stat.matches.length - maxThreads} more.`, })); } $.add(details, list); $.add(row, details); } else { $.add(row, [ $.el('span', { className: 'filter-stat-count', textContent: summaryText }), $.tn(' '), $.el('code', { textContent: ruleText }), ]); } $.add(panel, row); } }, renderFilterStats(type, textarea, panel) { $.rmAll(panel); if (!g.BOARD?.threads || (g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'catalog')) { $.add(panel, $.el('div', { className: 'filter-stats-empty', textContent: 'Thread match preview is available on board and catalog pages.', })); return; } const entries = Settings.filterPreviewEntries(); if (!entries.length) { $.add(panel, $.el('div', { className: 'filter-stats-empty', textContent: 'No loaded thread data to preview.', })); return; } const lines = textarea.value.split('\n'); if (type === 'MD5') { let activeLines = 0; for (const line of lines) { const trimmed = line.trim(); if (trimmed && trimmed[0] !== '#') activeLines++; } if (activeLines > 250) { $.add(panel, $.el('div', { className: 'filter-stats-empty', textContent: `Preview disabled for MD5 while ${activeLines} lines are loaded.`, })); return; } } const stats = []; let totalMatches = 0; let totalHidden = 0; lines.forEach((line, i) => { const trimmed = line.trim(); if (!trimmed) return; const parsed = Settings.parseFilterPreviewLine(type, line); if (parsed?.skip) return; if (parsed?.invalid) { stats.push({ lineNo: i + 1, line, invalid: parsed.invalid }); return; } const result = Settings.collectFilterPreviewMatches(parsed, entries); totalMatches += result.matches.length; totalHidden += result.hiddenThreadCount; stats.push({ lineNo: i + 1, line, matches: result.matches, hiddenThreadCount: result.hiddenThreadCount }); }); if (!stats.length) { $.add(panel, $.el('div', { className: 'filter-stats-empty', textContent: 'No filter lines to preview.', })); return; } $.add(panel, $.el('div', { className: 'filter-stats-summary', textContent: `${stats.length} line${stats.length === 1 ? '' : 's'}, ${totalMatches} matching thread${totalMatches === 1 ? '' : 's'}, ${totalHidden} hidden thread${totalHidden === 1 ? '' : 's'}.`, })); const maxThreads = 50; for (const stat of stats) { const row = $.el('div', { className: 'filter-stat-row' }); if (stat.invalid) { $.add(row, $.el('div', { className: 'filter-stat-invalid', textContent: `Line ${stat.lineNo}: invalid regex (${stat.invalid})`, })); $.add(panel, row); continue; } let lineText = stat.line.trim(); if (lineText.length > 120) lineText = `${lineText.slice(0, 117)}...`; const matchCount = stat.matches.length; let summaryText = `Line ${stat.lineNo}: ${matchCount} matching thread${matchCount === 1 ? '' : 's'}`; if (stat.hiddenThreadCount) summaryText += `, ${stat.hiddenThreadCount} hidden`; if (matchCount) { const details = $.el('details', { className: 'filter-stat' }); const summaryEl = $.el('summary'); $.add(summaryEl, [ $.el('span', { className: 'filter-stat-count', textContent: summaryText }), $.tn(' '), $.el('code', { textContent: lineText }), ]); $.add(details, summaryEl); const list = $.el('ul', { className: 'filter-stat-threads' }); stat.matches.slice(0, maxThreads).forEach((m) => { const { entry, hidesThread } = m; const { href, text } = Settings.filterPreviewThreadLink(entry); const li = $.el('li'); $.add(li, $.el('a', { href, textContent: text })); if (hidesThread) $.add(li, $.tn(' (hidden)')); $.add(list, li); }); if (stat.matches.length > maxThreads) { $.add(list, $.el('li', { className: 'filter-stat-more', textContent: `...and ${stat.matches.length - maxThreads} more.`, })); } $.add(details, list); $.add(row, details); } else { $.add(row, [ $.el('span', { className: 'filter-stat-count', textContent: summaryText }), $.tn(' '), $.el('code', { textContent: lineText }), ]); } $.add(panel, row); } }, parseFilterPreviewLine(key, line) { if (line[0] === '#') return { skip: true }; const regexpMatch = line.match(/\/(.*)\/(\w*)/); if (!regexpMatch) return { skip: true }; const isstring = key === 'uniqueID' || key === 'MD5'; let regexp = regexpMatch[1]; if (!isstring) { try { regexp = RegExp(regexpMatch[1], regexpMatch[2]); } catch (err) { return { invalid: err instanceof Error ? err.message : String(err) }; } } const filter = line.replace(regexpMatch[0], ''); const boards = Filter.parseBoards(filter.match(/(?:^|;)\s*boards:([^;]+)/)?.[1]); const excludes = Filter.parseBoards(filter.match(/(?:^|;)\s*exclude:([^;]+)/)?.[1]); const op = filter.match(/(?:^|;)\s*op:(no|only)/)?.[1] || ''; let mask = { no: 1, only: 2 }[op] || 0; const file = filter.match(/(?:^|;)\s*file:(no|only)/)?.[1] || ''; mask = mask | ({ no: 4, only: 8 }[file] || 0); const noti = /(?:^|;)\s*notify/.test(filter); const hl = /(?:^|;)\s*highlight/.test(filter); const hide = !(hl || noti); const keys = (key === 'general') ? (filter.match(/(?:^|;)\s*type:([^;]*)/)?.[1].split(',') || ['subject', 'name', 'filename', 'comment']) : [key]; return { regexp, isstring, boards, excludes, mask, hide, keys }; }, collectFilterPreviewMatches(parsed, entries) { const matches = []; let hiddenThreadCount = 0; for (const entry of entries) { let threadMatched = false; let hidesThread = false; for (const post of entry.posts) { if (!Settings.filterPreviewMatchesPost(parsed, post)) continue; threadMatched = true; if (parsed.hide && !post.isReply && !QuoteYou.isYou(post)) hidesThread = true; } if (threadMatched) { matches.push({ entry, hidesThread }); if (hidesThread) hiddenThreadCount++; } } return { matches, hiddenThreadCount }; }, filterPreviewMatchesPost(parsed, post) { let mask = post.isReply ? 2 : 1; mask = mask | (post.file ? 4 : 8); const board = `${post.siteID}/${post.boardID}`; const site = `${post.siteID}/*`; if ((parsed.boards && !(parsed.boards[board] || parsed.boards[site])) || (parsed.excludes && (parsed.excludes[board] || parsed.excludes[site])) || (parsed.mask & mask)) return false; for (const key of parsed.keys) { for (const value of Filter.values(key, post)) { if (parsed.isstring) { if (parsed.regexp === value) return true; } else { parsed.regexp.lastIndex = 0; if (parsed.regexp.test(value)) return true; } } } return false; }, filterPreviewEntries() { const entries = []; if (g.VIEW === 'index' && Index?.parsedThreads) { for (const threadID in Index.parsedThreads) { const parsed = Index.parsedThreads[threadID]; const thread = g.BOARD?.threads?.get?.(+threadID) || g.BOARD?.threads?.get?.(threadID); const posts = []; if (thread?.posts) { thread.posts.forEach((post) => { if (post.isClone || post.isFetchedQuote) return; posts.push(post); }); } if (!posts.length) posts.push(parsed); entries.push({ id: +threadID, boardID: parsed.boardID, siteID: parsed.siteID, thread, op: parsed, posts }); } return entries; } if (g.VIEW === 'catalog' && Filter?.catalogData) { for (const threadID in Filter.catalogData) { const data = Filter.catalogData[threadID]; const parsed = g.SITE.Build.parseJSON(data, g.BOARD); const thread = g.BOARD?.threads?.get?.(+threadID) || g.BOARD?.threads?.get?.(threadID); const posts = []; if (thread?.posts) { thread.posts.forEach((post) => { if (post.isClone || post.isFetchedQuote) return; posts.push(post); }); } if (!posts.length) posts.push(parsed); entries.push({ id: +threadID, boardID: parsed.boardID, siteID: parsed.siteID, thread, op: parsed, posts }); } return entries; } if (g.BOARD?.threads) { g.BOARD.threads.forEach((thread) => { if (!thread?.OP || thread.OP.isFetchedQuote) return; const posts = []; thread.posts.forEach((post) => { if (post.isClone || post.isFetchedQuote) return; posts.push(post); }); if (!posts.length) posts.push(thread.OP); entries.push({ id: thread.ID, boardID: thread.boardID, siteID: thread.siteID, thread, op: thread.OP, posts }); }); } return entries; }, filterPreviewThreadLink(entry) { const { id, boardID, op } = entry; let href = g.SITE.Build.postURL?.(boardID, id, id) || g.SITE.Build.threadURL?.(boardID, id) || ''; if (!href) href = `#p${id}`; let title = op.info.subject || op.info.comment || op.info.nameBlock || ''; if (!title && op.info.commentHTML?.innerHTML) { title = g.sites[op.siteID]?.Build?.parseComment?.(op.info.commentHTML.innerHTML) || ''; } title = title.replace(/\s+/g, ' ').trim(); if (title.length > 90) title = `${title.slice(0, 87)}...`; let text = `/${boardID}/${id}`; if (title) text += ` - ${title}`; return { href, text }; }, sauce(section) { $.extend(section, { innerHTML: SaucePage }); $('.warning', section).hidden = Conf['Sauce']; const ta = $('textarea', section); $.get('sauces', Conf['sauces'], function (item) { ta.value = item['sauces']; ta.hidden = false; Settings.prepareAutosaveTextarea(ta); }); $.on(ta, 'change', $.cb.value); }, advanced(section) { let input, name; $.extend(section, { innerHTML: AdvancedPage }); for (var warning of $$('.warning', section)) { warning.hidden = Conf[warning.dataset.feature]; } const inputs = dict(); for (input of $$('[name]', section)) { inputs[input.name] = input; } $.on(inputs['archiveLists'], 'change', function () { $.set('lastarchivecheck', 0); Conf['lastarchivecheck'] = 0; $.id('lastarchivecheck').textContent = 'never'; }); const xEmbedder = inputs['XEmbedder']; if (xEmbedder) { const xEmbedOptions = $$('.xembed-options', section); const syncXEmbedOptions = () => { for (const group of xEmbedOptions) { group.hidden = group.dataset.xembedder !== xEmbedder.value; } }; $.on(xEmbedder, 'change', syncXEmbedOptions); xEmbedder.value = Conf['XEmbedder']; syncXEmbedOptions(); } const items = dict(); for (name in inputs) { input = inputs[name]; if (!['Interval', 'Custom CSS', 'timeLocale'].includes(name)) { items[name] = Conf[name]; var event = ((input.nodeName === 'SELECT') || ['checkbox', 'radio'].includes(input.type) || ((input.nodeName === 'TEXTAREA') && !(name in Settings))) ? 'change' : 'input'; $.on(input, event, $.cb[input.type === 'checkbox' ? 'checked' : 'value']); if (name in Settings) { $.on(input, event, Settings[name]); } } } $.get(items, function (items) { for (var key in items) { var val = items[key]; input = inputs[key]; input[input.type === 'checkbox' ? 'checked' : 'value'] = val; input.hidden = false; if (input.nodeName === 'TEXTAREA') { Settings.prepareAutosaveTextarea(input); } if (key in Settings) { Settings[key].call(input); } } }); const listImageHost = $.id('list-fourchanImageHost'); if (listImageHost) { for (const textContent of ImageHost.suggestions) { $.add(listImageHost, $.el('option', { textContent })); } } const personaTA = $('.personafield', section); if (personaTA) { Settings.bindPlainEditorKeys(personaTA, '#'); Settings.makeDetachable(personaTA.closest('details'), $('#personas-detach', section), { storageKey: 'settings.detachPanel.personas', title: 'Personas' }); } const interval = inputs['Interval']; const customCSS = inputs['Custom CSS']; const timeLocale = inputs.timeLocale; if (interval) { interval.value = Conf['Interval']; $.on(interval, 'change', ThreadUpdater.cb.interval); } if (customCSS) { customCSS.checked = Conf['Custom CSS']; $.on(customCSS, 'change', Settings.togglecss); } if (inputs['usercss']) { inputs['usercss'].disabled = !Conf['Custom CSS']; } if (timeLocale) { timeLocale.value = Conf.timeLocale; $.on(timeLocale, 'change', Settings.setTimeLocale); } const itemsArchive = dict(); for (name of ['archives', 'selectedArchives', 'lastarchivecheck']) { itemsArchive[name] = Conf[name]; } $.get(itemsArchive, function (itemsArchive) { $.extend(Conf, itemsArchive); Redirect.selectArchives(); Settings.addArchiveTable(section); }); const boardSelect = $('#archive-board-select', section); const table = $('#archive-table', section); const updateArchives = $('#update-archives', section); if (boardSelect && table) { $.on(boardSelect, 'change', function () { const active = $('tbody > :not([hidden])', table); if (active) active.hidden = true; const next = $(`tbody > .${this.value}`, table); if (next) next.hidden = false; }); } if (updateArchives) { $.on(updateArchives, 'click', () => Redirect.update(() => Settings.addArchiveTable(section))); } if (inputs.beepVolume) { const volHint = inputs.beepVolume.closest('.sound-row')?.querySelector('.beep-volume-value'); const syncBeepVolumeLabel = () => { if (volHint) { volHint.textContent = `${Math.round(Number(inputs.beepVolume.value) * 100)}%`; } }; const previewBeepVolume = () => { $.cb.value.call(inputs.beepVolume); syncBeepVolumeLabel(); ThreadUpdater.playBeep(false); }; syncBeepVolumeLabel(); $.on(inputs.beepVolume, 'input', previewBeepVolume); $.on(inputs.beepVolume, 'change', previewBeepVolume); } Settings.addSoundLibrary(section); Settings.addBoardSoundOverrides(section); Settings.addPostSoundOverrides(section); }, addSoundLibrary(section) { SoundManager.init(); const fileInput = $('#sound-upload', section); const uploadBtn = $('#sound-upload-btn', section); const list = $('#sound-library-list', section); const render = () => { $.rmAll(list); const entries = SoundManager.library(); if (!entries.length) { const empty = $.el('div', { className: 'sound-list__empty', textContent: 'No sounds yet — add one above.' }); $.add(list, empty); } for (const lib of entries) { const isDefault = SoundManager.isDefault(lib.id); const row = $.el('div', { className: 'sound-list__row' + (lib.builtin ? ' sound-list__row--builtin' : '') + (isDefault ? ' sound-list__row--default' : ''), }); const radioWrap = $.el('label', { className: 'sound-list__default', title: 'Use as default sound' }); const radio = $.el('input', { type: 'radio', name: 'sound-default' }); radio.checked = isDefault; $.on(radio, 'change', () => { SoundManager.setDefaultSoundId(lib.id, render); }); $.add(radioWrap, radio); const nameCell = $.el('div', { className: 'sound-list__name' }); if (lib.builtin) { const label = $.el('span', { className: 'sound-list__name-label', textContent: lib.name }); const badge = $.el('span', { className: 'sound-list__badge', textContent: 'built-in' }); $.add(nameCell, [label, badge]); } else { const nameInput = $.el('input', { type: 'text', value: lib.name, className: 'field sound-list__name-input' }); $.on(nameInput, 'change', () => SoundManager.renameLibraryEntry(lib.id, nameInput.value)); $.add(nameCell, nameInput); } const actions = $.el('div', { className: 'sound-list__actions' }); const playBtn = $.el('button', { type: 'button', className: 'sound-btn sound-btn--icon', title: 'Preview', textContent: '▶' }); $.on(playBtn, 'click', () => ThreadUpdater.playSound(lib.data, false)); $.add(actions, playBtn); if (!lib.builtin) { const del = $.el('button', { type: 'button', className: 'sound-btn sound-btn--icon sound-btn--danger', title: 'Delete', textContent: '✕' }); $.on(del, 'click', () => SoundManager.removeFromLibrary(lib.id, render)); $.add(actions, del); } $.add(row, [radioWrap, nameCell, actions]); $.add(list, row); } Settings.refreshBoardSoundsSelect(section); }; $.on(uploadBtn, 'click', () => fileInput.click()); $.on(fileInput, 'change', () => { const files = Array.from(fileInput.files || []); if (!files.length) return; let remaining = files.length; const finish = () => { if (--remaining === 0) { fileInput.value = ''; render(); } }; for (const file of files) { const reader = new FileReader(); reader.onload = () => { const data = reader.result; const name = file.name.replace(/\.[^.]+$/, ''); SoundManager.addToLibrary(name, data, finish); }; reader.onerror = finish; reader.readAsDataURL(file); } }); Settings.wireSoundUrlAdd(section, render); Settings.wireSoundExportImport(section, render); render(); }, wireSoundUrlAdd(section, render) { const urlBtn = $('#sound-url-btn', section); const urlRow = $('#sound-url-row', section); const urlInput = $('#sound-url-input', section); const urlAdd = $('#sound-url-add', section); const urlCancel = $('#sound-url-cancel', section); const status = $('#sound-status', section); const setStatus = (msg, ok = false) => { status.textContent = msg; status.hidden = !msg; status.dataset.kind = ok ? 'ok' : 'error'; }; const showRow = (visible) => { urlRow.hidden = !visible; if (visible) { urlInput.focus(); setStatus(''); } }; $.on(urlBtn, 'click', () => showRow(urlRow.hidden)); $.on(urlCancel, 'click', () => { urlInput.value = ''; showRow(false); }); $.on(urlAdd, 'click', () => { const url = urlInput.value.trim(); if (!url) return; setStatus('Fetching…', true); fetch(url) .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); }) .then(blob => new Promise((resolve, reject) => { const fr = new FileReader(); fr.onload = () => resolve(fr.result); fr.onerror = () => reject(fr.error); fr.readAsDataURL(blob); })) .then(dataUri => { const name = url.split('/').pop()?.replace(/\.[^.]+$/, '') || 'sound'; SoundManager.addToLibrary(name, dataUri, () => { urlInput.value = ''; showRow(false); setStatus(''); render(); }); }) .catch(err => { setStatus(`Could not fetch: ${err.message || err}. The host may block cross-origin requests.`); }); }); }, wireSoundExportImport(section, render) { const exportBtn = $('#sound-export-btn', section); const importBtn = $('#sound-import-btn', section); const importInput = $('#sound-import', section); const status = $('#sound-status', section); const setStatus = (msg, ok = false) => { status.textContent = msg; status.hidden = !msg; status.dataset.kind = ok ? 'ok' : 'error'; }; $.on(exportBtn, 'click', () => { const payload = { kind: '4chan-neXT sound export', version: 1, date: Date.now(), soundLibrary: Conf.soundLibrary || [], boardSounds: Conf.boardSounds || {}, defaultSoundId: Conf.defaultSoundId || '', sounds: Conf['sounds'] || {}, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = $.el('a', { href: url, download: `4chan-neXT-sounds-${new Date().toISOString().slice(0, 10)}.json`, }); $.add(d.body, a); a.click(); $.rm(a); setTimeout(() => URL.revokeObjectURL(url), 0); }); $.on(importBtn, 'click', () => importInput.click()); $.on(importInput, 'change', () => { const file = importInput.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const data = JSON.parse(reader.result); if (data.kind !== '4chan-neXT sound export') { setStatus('Not a recognized sound export.'); return; } Settings.mergeSoundImport(data, () => { importInput.value = ''; render(); setStatus(`Imported.`, true); }); } catch (err) { setStatus(`Import failed: ${err instanceof Error ? err.message : String(err)}`); } }; reader.readAsText(file); }); }, mergeSoundImport(data, cb) { const existing = Array.isArray(Conf.soundLibrary) ? Conf.soundLibrary : []; const seen = new Set(existing.map((e) => e.id)); const merged = [...existing]; for (const entry of (data.soundLibrary || [])) { if (entry?.id && !seen.has(entry.id)) { merged.push(entry); seen.add(entry.id); } } Conf.soundLibrary = merged; Conf.boardSounds = { ...(Conf.boardSounds || {}), ...(data.boardSounds || {}) }; if (data.defaultSoundId && seen.has(data.defaultSoundId)) { Conf.defaultSoundId = data.defaultSoundId; } const cur = (Conf['sounds'] && typeof Conf['sounds'] === 'object') ? Conf['sounds'] : dict(); const next = JSON.parse(JSON.stringify(cur)); const inSounds = data.sounds || {}; for (const siteID in inSounds) { const site = inSounds[siteID]; if (!site?.boards) continue; if (!next[siteID]) next[siteID] = { boards: dict() }; for (const boardID in site.boards) { const board = site.boards[boardID]; if (!next[siteID].boards[boardID]) next[siteID].boards[boardID] = dict(); for (const threadID in board) { const wrapper = board[threadID]; if (!wrapper) continue; const cur = next[siteID].boards[boardID][threadID] || {}; const curPosts = { ...(cur.posts || {}) }; const inPosts = wrapper.posts || {}; for (const postID in inPosts) { if (!(postID in curPosts)) curPosts[postID] = inPosts[postID]; } if (Object.keys(curPosts).length) { next[siteID].boards[boardID][threadID] = { ...cur, posts: curPosts }; } } } } Conf['sounds'] = next; $.set({ soundLibrary: Conf.soundLibrary, boardSounds: Conf.boardSounds, defaultSoundId: Conf.defaultSoundId, sounds: Conf['sounds'], }, cb); }, addUpdaterBoardSound(root) { SoundManager.init(); const boardID = g.BOARD?.ID; if (!boardID) { $.add(root, $.el('div', { className: 'description', textContent: 'Open a board to pick its update sound here (or set overrides in Advanced).', })); return; } const div = $.el('div'); div.dataset.name = 'board-update-sound'; const label = $.el('label'); $.add(label, $.el('span', { className: 'setting-title', textContent: `Board update sound (/${boardID}/): ` })); const select = $.el('select', { className: 'field', name: 'board-update-sound' }); const previewBtn = $.el('button', { type: 'button', className: 'sound-btn', textContent: 'Preview', title: 'Play the selected sound', }); const fillOptions = () => { const current = SoundManager.getBoardOverride(boardID); $.rmAll(select); $.add(select, $.el('option', { value: '', textContent: 'Default (inherit)' })); for (const lib of SoundManager.library()) { const opt = $.el('option', { value: lib.id, textContent: lib.name }); if (lib.id === current) opt.selected = true; $.add(select, opt); } if (current && !SoundManager.getEntry(current)) { const missing = $.el('option', { value: current, textContent: `(missing: ${current})` }); missing.selected = true; $.add(select, missing); } }; fillOptions(); $.on(select, 'change', () => { const soundId = select.value || null; SoundManager.setBoardOverride(boardID, soundId); if (soundId) { const entry = SoundManager.getEntry(soundId); if (entry?.data) ThreadUpdater.playSound(entry.data, false); } }); $.on(previewBtn, 'click', () => { const soundId = select.value; if (!soundId) { ThreadUpdater.playBeep(false); return; } const entry = SoundManager.getEntry(soundId); if (entry?.data) ThreadUpdater.playSound(entry.data, false); }); $.add(label, select); $.add(div, [ label, previewBtn, Settings.descriptionSpan('Plays on new posts in this board.'), ]); $.add(root, div); }, addBoardSoundOverrides(section) { const list = $('#board-sounds-list', section); const boardInput = $('#board-sounds-board', section); const soundSelect = $('#board-sounds-sound', section); const addBtn = $('#board-sounds-add', section); Settings.populateBoardDatalist(section); const render = () => { $.rmAll(list); const all = SoundManager.allBoardOverrides(); if (!all.length) { const empty = $.el('div', { className: 'sound-list__empty', textContent: 'No board overrides yet.' }); $.add(list, empty); return; } for (const { siteID, boardID, soundId } of all) { const lib = SoundManager.getEntry(soundId); const row = $.el('div', { className: 'sound-list__row' }); const nameCell = $.el('div', { className: 'sound-list__name' }); const board = $.el('span', { className: 'sound-list__board', title: `${siteID}/${boardID}`, textContent: `/${boardID}/` }); const arrow = $.el('span', { className: 'sound-list__sep', textContent: '→' }); const sound = $.el('span', { className: 'sound-list__sound' + (lib ? '' : ' sound-list__sound--missing'), textContent: lib?.name || `(missing: ${soundId})`, }); $.add(nameCell, [board, arrow, sound]); const actions = $.el('div', { className: 'sound-list__actions' }); const del = $.el('button', { type: 'button', className: 'sound-btn sound-btn--icon sound-btn--danger', title: 'Remove', textContent: '✕' }); $.on(del, 'click', () => { const map = { ...(Conf.boardSounds || {}) }; delete map[`${siteID}/${boardID}`]; Conf.boardSounds = map; $.set('boardSounds', map, render); }); $.add(actions, del); $.add(row, [nameCell, actions]); $.add(list, row); } }; $.on(addBtn, 'click', () => { const boardID = boardInput.value.trim(); const soundId = soundSelect.value; if (!boardID || !soundId) return; SoundManager.setBoardOverride(boardID, soundId, render); boardInput.value = ''; }); Settings.refreshBoardSoundsSelect(section); render(); }, refreshBoardSoundsSelect(section) { const sel = $('#board-sounds-sound', section); if (!sel) return; $.rmAll(sel); for (const lib of SoundManager.library()) { const opt = $.el('option', { value: lib.id, textContent: lib.name }); $.add(sel, opt); } }, populateBoardDatalist(section) { const datalist = $('#board-sounds-datalist', section); if (!datalist) return; $.rmAll(datalist); const boards = (Conf['boardConfig']?.boards) || {}; const seen = new Set(); for (const id in boards) { if (seen.has(id)) continue; seen.add(id); const data = boards[id] || {}; const title = data.title ? `/${id}/ - ${data.title}` : `/${id}/`; $.add(datalist, $.el('option', { value: id, textContent: title })); } for (const { boardID } of SoundManager.allBoardOverrides()) { if (seen.has(boardID)) continue; seen.add(boardID); $.add(datalist, $.el('option', { value: boardID })); } }, addPostSoundOverrides(section) { const list = $('#post-sounds-list', section); const render = () => { $.rmAll(list); const all = SoundManager.allPostOverrides(); if (!all.length) { const empty = $.el('div', { className: 'sound-list__empty', textContent: 'No post overrides yet.' }); $.add(list, empty); return; } for (const { siteID, boardID, threadID, postID, soundId } of all) { const lib = SoundManager.getEntry(soundId); const row = $.el('div', { className: 'sound-list__row' }); const nameCell = $.el('div', { className: 'sound-list__name' }); const ref = $.el('span', { className: 'sound-list__board', textContent: `${siteID}/${boardID}/${threadID}#${postID}` }); const arrow = $.el('span', { className: 'sound-list__sep', textContent: '→' }); const sound = $.el('span', { className: 'sound-list__sound' + (lib ? '' : ' sound-list__sound--missing'), textContent: lib?.name || `(missing: ${soundId})`, }); $.add(nameCell, [ref, arrow, sound]); const actions = $.el('div', { className: 'sound-list__actions' }); const del = $.el('button', { type: 'button', className: 'sound-btn sound-btn--icon sound-btn--danger', title: 'Remove', textContent: '✕' }); $.on(del, 'click', () => { SoundManager.setPostOverride({ siteID, boardID, threadID, postID }, null, render); }); $.add(actions, del); $.add(row, [nameCell, actions]); $.add(list, row); } }; render(); }, addArchiveTable(section) { let boardID, o; $('#lastarchivecheck', section).textContent = Conf['lastarchivecheck'] === 0 ? 'never' : new Date(Conf['lastarchivecheck']).toLocaleString(); const boardSelect = $('#archive-board-select', section); const table = $('#archive-table', section); const tbody = $('tbody', section); $.rmAll(boardSelect); $.rmAll(tbody); const archBoards = dict(); for (var { uid, name, boards, files, software } of Conf['archives']) { if (!['fuuka', 'foolfuuka'].includes(software)) { continue; } for (boardID of boards) { o = archBoards[boardID] || (archBoards[boardID] = { thread: [], threadJSON: [], post: [], file: [] }); if (!o.threadJSON) o.threadJSON = []; var archive = [uid ?? name, name]; o.thread.push(archive); if (software === 'foolfuuka') { o.post.push(archive); o.threadJSON.push(archive); } if (files.includes(boardID)) { o.file.push(archive); } } } const rows = []; const boardOptions = []; for (boardID of Object.keys(archBoards).sort()) { var row = $.el('tr', { className: `board-${boardID}` }); row.hidden = boardID !== g.BOARD.ID; boardOptions.push($.el('option', { textContent: `/${boardID}/`, value: `board-${boardID}`, selected: boardID === g.BOARD.ID })); o = archBoards[boardID]; for (var item of ['thread', 'threadJSON', 'post', 'file']) { $.add(row, Settings.addArchiveCell(boardID, o, item)); } rows.push(row); } if (rows.length === 0) { boardSelect.hidden = (table.hidden = true); return; } boardSelect.hidden = (table.hidden = false); if (!(g.BOARD.ID in archBoards)) { rows[0].hidden = false; } $.add(boardSelect, boardOptions); $.add(tbody, rows); for (boardID in Conf['selectedArchives']) { var data = Conf['selectedArchives'][boardID]; for (var type in data) { var select; var id = data[type]; if (select = $(`select[data-boardid='${boardID}'][data-type='${type}']`, tbody)) { select.value = JSON.stringify(id); if (!select.value) { select.value = select.firstChild.value; } } } } }, addArchiveCell(boardID, data, type) { const { length } = data[type]; const td = $.el('td', { className: 'archive-cell' }); td.dataset.label = { thread: 'Thread redirection', threadJSON: 'Thread fetching', post: 'Post fetching', file: 'File redirection', }[type] || type; if (!length) { td.textContent = '--'; return td; } const options = []; let i = 0; while (i < length) { var archive = data[type][i++]; options.push($.el('option', { value: JSON.stringify(archive[0]), textContent: archive[1] })); } $.extend(td, { innerHTML: '' }); const select = td.firstElementChild; if (!(select.disabled = length === 1)) { select.setAttribute('data-boardid', boardID); select.setAttribute('data-type', type); $.on(select, 'change', Settings.saveSelectedArchive); } $.add(select, options); return td; }, saveSelectedArchive() { $.get('selectedArchives', Conf['selectedArchives'], ({ selectedArchives }) => { (selectedArchives[this.dataset.boardid] || (selectedArchives[this.dataset.boardid] = dict()))[this.dataset.type] = JSON.parse(this.value); $.set('selectedArchives', selectedArchives); Conf['selectedArchives'] = selectedArchives; Redirect.selectArchives(); }); }, boardnav() { Header.generateBoardList(this.value); }, time() { this.nextElementSibling.textContent = Time.format(new Date(), this.value); }, timeLocale() { Settings.time.call($('[name=time]', Settings.dialog)); }, backlink() { this.nextElementSibling.textContent = this.value.replace(/%(?:id|%)/g, x => ({ '%id': '123456789', '%%': '%' }[x])); }, fileInfo() { const data = { isReply: true, file: { url: `//${ImageHost.host()}/g/1334437723720.jpg`, name: 'd9bb2efc98dd0df141a94399ff5880b7.jpg', size: '276 KB', sizeInBytes: 276 * 1024, dimensions: '1280x720', isImage: true, isVideo: false, isSpoiler: true, tag: 'Loop' } }; FileInfo.format(this.value, data, this.nextElementSibling); }, favicon() { Favicon.switch(); if ((g.VIEW === 'thread') && Conf['Unread Favicon']) { Unread.update(); } const img = this.nextElementSibling.children; const f = Favicon; const iterable = [f.SFW, f.unreadSFW, f.unreadSFWY, f.NSFW, f.unreadNSFW, f.unreadNSFWY, f.dead, f.unreadDead, f.unreadDeadY]; for (let i = 0; i < iterable.length; i++) { var icon = iterable[i]; if (!img[i]) { $.add(this.nextElementSibling, $.el('img')); } img[i].src = icon; } }, togglecss() { const details = $.x('ancestor::details[1]', this); const textarea = details ? $('.custom-css-textarea', details) : null; const disabled = !this.checked; if (textarea) textarea.disabled = disabled; if (disabled) { CustomCSS.rmStyle(); } else { CustomCSS.addStyle(); } $.cb.checked.call(this); Settings.applyStylingVars(); }, setTimeLocale(e) { const input = e.target; try { if (input.value !== '') new Intl.DateTimeFormat(input.value); input.setCustomValidity(''); Time.formatterCache.clear(); $.cb.value.call(input); Settings.timeLocale.call(input); } catch (e) { input.setCustomValidity('Locale not recognized'); input.reportValidity(); } }, keyBindInputs: dict(), keybinds(section) { let key; $.extend(section, { innerHTML: KeybindsPage }); const warning = $('.warning', section); if (warning) warning.hidden = Conf['Keybinds']; const details = $.el('details', { open: true }, { innerHTML: 'Keybind Actions' }); const contentNodes = [...section.childNodes]; $.rmAll(section); $.add(details, contentNodes); $.add(section, details); const tbody = $('tbody', details); const items = dict(); const inputs = Settings.keyBindInputs; for (key in Config.hotkeys) { var arr = Config.hotkeys[key]; var marker = Settings.isClickKeybind(key) ? '*' : ''; var tr = $.el('tr', { innerHTML: `${marker}${arr[1]}` }); tr.dataset.name = `${key} ${arr[1]}`; tr.dataset.settingTitle = arr[1]; tr.dataset.settingDescription = key; var input = $('input', tr); input.name = key; input.spellcheck = false; items[key] = Conf[key]; inputs[key] = input; $.on(input, 'keydown', Settings.keybind); $.add(tbody, tr); } $.get(items, function (items) { for (key in items) { var val = items[key]; inputs[key].value = val; } }); $.on($('#reset-keys', details), 'click', Settings.resetKeybinds); }, keybind(e) { if (e.keyCode === 9) return; e.preventDefault(); e.stopPropagation(); const key = Keybinds.keyCode(e); if (Settings.isClickKeybind(this.name)) { if (key === '') { this.value = ''; $.cb.value.call(this); return; } const mods = Keybinds.modifierString(e); if (!mods) return; this.value = mods; $.cb.value.call(this); return; } if (key == null) return; this.value = key; $.cb.value.call(this); }, resetKeybinds() { if (!confirm('Are you sure you want to reset the keybinds?')) return; const defaults = Object.fromEntries(Object.entries(Config.hotkeys).map(([key, value]) => [key, value[0]])); $.set(defaults, () => { Object.assign(Conf, defaults); for (const [key, value] of Object.entries(defaults)) { Settings.keyBindInputs[key].value = value; } }); }, }; var Filter = { filters: new Map(), init() { if (g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'catalog' || !Conf['Filter']) return; if ((g.VIEW === 'catalog') && !Conf['Filter in Native Catalog']) return; if (!Conf['Filtered Backlinks']) { $.addClass(doc, 'hide-backlinks'); } const easyLines = Filter.easyFilterLines(); Filter.injectEasyHighlightColors(); for (var key in Config.filter) { const lines = Conf[key].split('\n'); if (key === 'general' && easyLines.length) lines.push(...easyLines); for (var line of lines) { let hl; let tile = false; let regexp; let top; let hide = true; let mask = 0; let boards = false; let excludes = false; let reason; let poster = false; let replies = false; let noti = false; let override = false; let stub = Conf.Stubs; if (line[0] === '#') continue; const regexpMatch = line.match(/\/(.*)\/(\w*)/); if (!regexpMatch) { continue; } if (key === 'uniqueID' || key === 'MD5') { regexp = regexpMatch[1]; } else { try { regexp = RegExp(regexpMatch[1], regexpMatch[2]); } catch (err) { new Notice('warning', [ $.tn(`Invalid ${key} filter:`), $.el('br'), $.tn(line), $.el('br'), $.tn(err instanceof Error ? err.message : String(err)) ], 60); continue; } } const options = line.length > regexpMatch[0].length ? line.replace(regexpMatch[0], '') : ''; if (options) { boards = Filter.parseBoards(options.match(/(?:^|;)\s*boards:([^;]+)/)?.[1]); excludes = Filter.parseBoards(options.match(/(?:^|;)\s*exclude:([^;]+)/)?.[1]); const op = options.match(/(?:^|;)\s*op:(no|only)/)?.[1] || ''; mask = $.getOwn({ 'no': 1, 'only': 2 }, op) || 0; const file = options.match(/(?:^|;)\s*file:(no|only)/)?.[1] || ''; mask = mask | ($.getOwn({ 'no': 4, 'only': 8 }, file) || 0); stub = (() => { switch (options.match(/(?:^|;)\s*stub:(yes|no)/)?.[1]) { case 'yes': return true; case 'no': return false; default: return Conf['Stubs']; } })(); noti = /(?:^|;)\s*notify/.test(options); const highlightRes = options.match(/(?:^|;)\s*highlight(?::([\w,-]+))?/); if (highlightRes) { hl = highlightRes[1] || 'filter-highlight'; tile = /(?:^|;)\s*tile(?:[;:]|$)/.test(options); top = (options.match(/(?:^|;)\s*top:(yes|no)/)?.[1] || 'yes') === 'yes'; hide = /(?:^|;)\s*hide(?:[;:]|$)/.test(options); } override = !!hl && /(?:^|;)\s*override(?:[;:]|$)/.test(options); hide = hide || !(hl || noti); reason = options.match(/(?:^|;)\s*reason:([^;$]+)/)?.[1]; poster = /(?:^|;)\s*poster(?:[;:]|$)/.test(options); replies = /(?:^|;)\s*replies(?:[;:]|$)/.test(options); } const label = (regexp instanceof RegExp) ? `${key}: /${regexp.source}/${regexp.flags}` : `${key}: ${regexp}`; const filterObj = { regexp, boards, excludes, mask, hide, stub, hl, tile, top, noti, reason, poster, replies, override, label }; if (key === 'general') { const types = options.match(/(?:^|;)\s*type:([^;]*)/)?.[1].split(',') || ['subject', 'name', 'filename', 'comment']; for (var type of types) { Filter.filters.get(type)?.push(filterObj) ?? Filter.filters.set(type, [filterObj]); } } else { Filter.filters.get(key)?.push(filterObj) ?? Filter.filters.set(key, [filterObj]); } } } if (!Filter.filters.size) return; for (const type of ['MD5', 'uniqueID']) { const filtersForType = Filter.filters.get(type); if (!filtersForType) continue; const map = new Map(); for (const filter of filtersForType) { map.get(filter.regexp)?.push(filter) ?? map.set(filter.regexp, [filter]); } Filter.filters.set(type, map); } if (g.VIEW === 'catalog') { return Filter.catalog(); } else { return Callbacks.Post.push({ name: 'Filter', cb: Filter.node }); } }, parseBoards(boardsRaw) { let boards; if (!boardsRaw) { return false; } if (boards = Filter.parseBoardsMemo[boardsRaw]) { return boards; } boards = dict(); let siteFilter = ''; for (var boardID of boardsRaw.split(',')) { if (boardID.includes(':')) { [siteFilter, boardID] = boardID.split(':').slice(-2); } for (var siteID in g.sites) { var site = g.sites[siteID]; if (siteID.slice(0, siteFilter.length) === siteFilter) { if (['nsfw', 'sfw'].includes(boardID)) { for (var boardID2 of site.sfwBoards?.(boardID === 'sfw') || []) { boards[`${siteID}/${boardID2}`] = true; } } else { boards[`${siteID}/${encodeURIComponent(boardID)}`] = true; } } } } Filter.parseBoardsMemo[boardsRaw] = boards; return boards; }, parseBoardsMemo: dict(), catalogData: undefined, test(post, hideable = true) { if (post.filterResults) return post.filterResults; let hide = false; let stub = true; let hl = undefined; let top = false; let noti = false; let poster = false; let replies = false; let hlOverride = false; let reasons; if (QuoteYou.isYou(post)) { hideable = false; } let mask = (post.isReply ? 2 : 1); mask = (mask | (post.file ? 4 : 8)); const board = `${post.siteID}/${post.boardID}`; const site = `${post.siteID}/*`; for (const type of Filter.filters.keys()) { for (const value of Filter.values(type, post)) { const filtersOrMap = Filter.filters.get(type); const filtersForType = Array.isArray(filtersOrMap) ? filtersOrMap : filtersOrMap.get(value); if (!filtersForType) continue; const isString = type === 'uniqueID' || type === 'MD5'; for (const filter of filtersForType) { if ((filter.boards && !(filter.boards[board] || filter.boards[site])) || (filter.excludes && (filter.excludes[board] || filter.excludes[site])) || (filter.mask & mask) || (isString ? (filter.regexp !== value) : !filter.regexp.test(value))) continue; if (filter.hide) { if (hideable) { hide = true; if (stub) { ({ stub } = filter); (reasons || (reasons = [])).push(filter.reason || `Filtered ${type} ${filter.regexp}`); } } } if (filter.hl) { for (const cls of filter.hl.split(',')) { if (!cls || hl?.includes(cls)) continue; (hl || (hl = [])).push(cls); if (cls.startsWith('xt-hl-') && !hl.includes('filter-highlight')) { hl.push('filter-highlight'); } } } if (filter.hl && filter.tile && !hl?.includes('filter-glow-tile')) { (hl || (hl = [])).push('filter-glow-tile'); } if (filter.override && filter.hl) hlOverride = true; if (!top) { ({ top } = filter); } if (filter.noti) noti = true; if (filter.poster) poster = true; if (filter.replies) replies = true; } } } if (hide && hlOverride) { hide = false; reasons = undefined; } post.filterResults = { hide, stub, hl, top, noti, poster, replies, reasons }; return post.filterResults; }, normalizeEasyRule(rule) { if (!rule || typeof rule !== 'object') return null; const pattern = typeof rule.pattern === 'string' ? rule.pattern : typeof rule.match === 'string' ? rule.match : ''; const type = (rule.type in Config.filter) ? rule.type : { title: 'subject', body: 'comment', name: 'name', }[rule.field] || 'general'; const hide = (rule.hide != null) ? !!rule.hide : !['highlight', 'notify'].includes(rule.action); const rawColor = typeof rule.color === 'string' ? rule.color.trim() : ''; const hex = /^#?([0-9a-fA-F]{6})$/.exec(rawColor); let hlClass = typeof rule.hlClass === 'string' ? rule.hlClass.trim() : ''; if (!hex && rawColor && !hlClass) hlClass = rawColor; hlClass = hlClass.replace(/^\.+/, '').replace(/[^\w-]/g, ''); return { enabled: rule.enabled != null ? !!rule.enabled : true, pattern, boards: typeof rule.boards === 'string' ? rule.boards : '', type, color: hex ? `#${hex[1].toLowerCase()}` : '#dd0000', colorOn: rule.colorOn != null ? !!rule.colorOn : !!hex, hlClass, auto: !!rule.auto, hide, override: !!rule.override, action: rule.action, caseSensitive: !!rule.caseSensitive, }; }, parseEasyFilterRules(raw) { let rules = []; if (Array.isArray(raw)) { rules = raw; } else if (typeof raw === 'string' && raw.trim()) { try { rules = JSON.parse(raw); } catch { rules = []; } } if (!Array.isArray(rules)) return []; return rules.map(r => Filter.normalizeEasyRule(r)).filter(Boolean); }, easyHighlightClasses(rule) { const classes = []; if (rule.colorOn && /^#[0-9a-f]{6}$/.test(rule.color)) { classes.push(`xt-hl-${rule.color.slice(1)}`); } if (rule.hlClass) classes.push(rule.hlClass); return classes; }, easyRuleToLine(rule) { if (!rule?.enabled) return null; const match = (rule.pattern || '').trim(); if (!match) return null; const flags = rule.caseSensitive ? '' : 'i'; let line = `/${Filter.escape(match)}/${flags}`; const options = []; if ((rule.boards || '').trim()) options.push(`boards:${rule.boards.trim()}`); const type = (rule.type in Config.filter) ? rule.type : 'general'; options.push(`type:${type === 'general' ? 'subject,name,comment' : type}`); if (!rule.hide) { const classes = Filter.easyHighlightClasses(rule); options.push(classes.length ? `highlight:${classes.join(',')}` : 'highlight'); options.push(`top:${rule.auto ? 'yes' : 'no'}`); if (rule.override) options.push('override'); } if (rule.action === 'notify') options.push('notify'); if (options.length) line += `;${options.join(';')}`; return line; }, easyFilterLines() { const lines = []; for (const rule of Filter.parseEasyFilterRules(Conf['easyFilters'])) { const line = Filter.easyRuleToLine(rule); if (line) lines.push(line); } return lines; }, injectEasyHighlightColors() { const colors = new Set(); for (const rule of Filter.parseEasyFilterRules(Conf['easyFilters'])) { if (!rule.enabled || rule.hide || !rule.colorOn) continue; const hex = /^#([0-9a-f]{6})$/.exec(rule.color); if (hex) colors.add(hex[1]); } const existing = $.id('xt-easy-highlight-colors'); if (existing) $.rm(existing); if (!colors.size) return; const css = [...colors].map(hex => `.xt-hl-${hex}{--xt-filter-highlight:#${hex};--xt-highlight-side-arrow:#${hex};--xt-highlight-shadow:#${hex};}`).join('\n'); $.addStyle(css, 'xt-easy-highlight-colors'); }, node() { if (this.isClone || (!this.isReply && !this.thread.nodes.root)) return; const { hide, stub, hl, noti, poster, replies, reasons } = Filter.test(this, (!this.isFetchedQuote && (this.isReply || (g.VIEW === 'index')))); let reason; if (poster && this.info.uniqueID) { reason = `Hidden because it's the same poster as ${this.ID} (${reasons})`; const { uniqueID } = this.info; const newFilter = { regexp: uniqueID, boards: false, excludes: false, mask: 0, hide, stub, replies, hl: hl?.[0], reason, }; const map = Filter.filters.get('uniqueID'); if (map) { map.get(uniqueID)?.push(newFilter) ?? map.set(uniqueID, [newFilter]); } else { Filter.filters.set('uniqueID', (new Map()).set(uniqueID, [newFilter])); } } if (hide) { if (this.isReply) { PostHiding.hide(this, stub); if (replies) { Recursive.applyAndAdd(PostHiding.hide, this, stub, undefined, `Hidden recursively from ${this.ID}`); } if (poster && this.info.uniqueID) { g.posts.forEach((p) => { if (p.info.uniqueID === this.info.uniqueID && p !== this) { PostHiding.hide(p, stub, replies, reason); if (replies) { Recursive.applyAndAdd(PostHiding.hide, p, stub, undefined, `Hidden recursively from ${p.ID}`); } } }); } } else { ThreadHiding.hide(this.thread, stub); } } if (hl) { this.highlights = hl; $.addClass(this.nodes.root, ...hl); if (this.isReply) { const hlFn = (post, ...hl) => { $.addClass(post.nodes.root, ...hl); }; if (replies) Recursive.applyAndAdd(hlFn, this, ...hl); if (poster && this.info.uniqueID) { g.posts.forEach((p) => { if (p.info.uniqueID === this.info.uniqueID && p !== this) { $.addClass(p.nodes.root, ...hl); if (replies) Recursive.applyAndAdd(hlFn, p, ...hl); } }); } } } if (noti && Unread.posts && (this.ID > Unread.lastReadPost) && !QuoteYou.isYou(this)) { Unread.openNotification(this, ' triggered a notification filter'); } if (this.file?.thumbLink) { $.on(this.file.thumbLink, 'click', (e) => { if (!e.shiftKey || !Conf['MD5 Quick Filter in Threads']) return; Filter.quickFilterMD5.call(this); e.preventDefault(); e.stopImmediatePropagation(); }); } }, catalog() { let url; if (!(url = g.SITE.urls.catalogJSON?.(g.BOARD))) { return; } Filter.catalogData = dict(); $.ajax(url, { onloadend: Filter.catalogParse }); return Callbacks.CatalogThreadNative.push({ name: 'Filter', cb: this.catalogNode }); }, catalogParse() { if (![200, 404].includes(this.status)) { new Notice('warning', `Failed to fetch catalog JSON data. ${this.status ? `Error ${this.statusText} (${this.status})` : 'Connection Error'}`, 1); return; } for (var page of this.response) { for (var item of page.threads) { Filter.catalogData[item.no] = item; } } g.BOARD.threads.forEach(function (thread) { if (thread.catalogViewNative) { return Filter.catalogNode.call(thread.catalogViewNative); } }); }, catalogNode() { if ((this.boardID !== g.BOARD.ID) || !Filter.catalogData[this.ID]) { return; } if (QuoteYou.db?.get({ siteID: g.SITE.ID, boardID: this.boardID, threadID: this.ID, postID: this.ID })) { return; } const { hide, hl, top } = Filter.test(g.SITE.Build.parseJSON(Filter.catalogData[this.ID], this)); if (hide) { this.nodes.root.hidden = true; } if (hl) { this.highlights = hl; $.addClass(this.nodes.root, ...hl); } if (top) { $.prepend(this.nodes.root.parentNode, this.nodes.root); g.SITE.catalogPin?.(this.nodes.root); } }, isHidden(post) { return !!Filter.test(post).hide; }, valueF: { postID(post) { return [`${post.ID}`]; }, name(post) { return post.info.name === undefined ? [] : [post.info.name]; }, uniqueID(post) { return [post.info.uniqueID || '']; }, tripcode(post) { return post.info.tripcode === undefined ? [] : [post.info.tripcode]; }, capcode(post) { return post.info.capcode === undefined ? [] : [post.info.capcode]; }, pass(post) { return post.info.pass === undefined ? [] : [post.info.pass]; }, email(post) { return post.info.email === undefined ? [] : [post.info.email]; }, subject(post) { return post.info.subject === undefined && post.isReply ? [] : [post.info.subject || '']; }, comment(post) { if (post.info.comment == null) { post.info.comment = g.sites[post.siteID]?.Build?.parseComment?.(post.info.commentHTML.innerHTML); } return [post.info.comment]; }, flag(post) { return post.info.flag === undefined ? [] : [post.info.flag]; }, filename(post) { return post.files.map(f => f.name); }, dimensions(post) { return post.files.map(f => f.dimensions).filter(v => v != null); }, filesize(post) { return post.files.map(f => f.size); }, MD5(post) { return post.files.map(f => f.MD5).filter(v => v != null); } }, values(key, post) { if ($.hasOwn(Filter.valueF, key)) { return Filter.valueF[key](post).filter((v) => v != null); } else { return [key.split('+').map(function (k) { let f; if (f = $.getOwn(Filter.valueF, k)) { return f(post).map(v => v || '').join('\n'); } else { return ''; } }).join('\n')]; } }, addFilter(type, re, cb) { if (!$.hasOwn(Config.filter, type)) { return; } return $.get(type, Conf[type], function (item) { const existingLines = (item[type] || '').split('\n'); const existingSet = new Set(existingLines.map((line) => line.trim()).filter(Boolean)); const linesToAdd = re.split('\n').map(line => line.trim()).filter(Boolean) .filter(line => !existingSet.has(line)); if (!linesToAdd.length) { return cb?.(); } const save = [...existingLines, ...linesToAdd].filter(Boolean).join('\n'); return $.set(type, save, cb); }); }, removeFilters(type, res, cb) { return $.get(type, Conf[type], function (item) { let save = item[type]; const filterArray = Array.isArray(res) ? res : [...res.values()].flat(); const r = filterArray.map(Filter.escape).join('|'); save = save.replace(RegExp(`(?:$\n|^)(?:${r})$`, 'mg'), ''); return $.set(type, save, cb); }); }, showFilters(type) { Settings.forcedFiltersMode = 'advanced'; Settings.forcedFilterType = type; if (Settings.dialog) { const filteringTab = $('.tab-filters', Settings.dialog); filteringTab?.click(); } else { Settings.open('Filter'); } const section = Settings.dialog ? $('section', Settings.dialog) : $('.section-container'); return $.onExists(section, `textarea[name="${type}"]`, function (ta) { const tl = ta.textLength; ta.setSelectionRange(tl, tl); return ta.focus(); }); }, quickFilterMD5(e) { const post = this instanceof Post ? this : Get.postFromNode(this); const files = post.files.filter(f => f.MD5); if (!files.length) { return; } const md5s = new Set(files.map(f => f.MD5)); const filter = files.map(f => `/${f.MD5}/`).join('\n'); Filter.addFilter('MD5', filter); const reason = files.map(f => `Filtered MD5 ${f.MD5}`).join(' & '); const origin = (post.origin || post); const hidden = []; const hideMatchingPost = (candidate) => { if (candidate.isHidden || !candidate.files.some(file => file.MD5 && md5s.has(file.MD5))) return; delete candidate.filterResults; if (candidate.isReply) { PostHiding.hide(candidate, undefined, undefined, reason); } else if (g.VIEW === 'index') { ThreadHiding.hide(candidate.thread); } hidden.push(candidate); }; if (g.VIEW === 'thread') { g.posts.forEach(hideMatchingPost); } else { hideMatchingPost(origin); } UndoStack.record('Removed MD5 filter', () => { Filter.removeFilters('MD5', [filter]); for (const p of hidden) { if (p.isReply) { PostHiding.show(p); } else if (g.VIEW === 'index') { ThreadHiding.show(p.thread); } } }); if (!Conf['MD5 Quick Filter Notifications']) { if (post.nodes.post.getBoundingClientRect().height) { new Notice('info', 'MD5 filtered.', 2); } return; } const qf = Filter.quickFilterMD5; let { notice } = qf; if (notice) { if (!notice.filters.includes(filter)) { notice.filters.push(filter); } if (!notice.posts.includes(origin)) { notice.posts.push(origin); } $('span', notice.el).textContent = `${notice.filters.length} MD5s filtered.`; notice.resetTimer(); } else { const msg = $.el('div', { innerHTML: "MD5 filtered. [show] [undo]" }); notice = (qf.notice = new Notice('info', msg, 10, () => delete qf.notice)); notice.filters = [filter]; notice.posts = [origin]; const links = $$('a', msg); $.on(links[0], 'click', Filter.quickFilterCB.show.bind(notice)); $.on(links[1], 'click', Filter.quickFilterCB.undo.bind(notice)); } }, quickFilterCB: { show() { Settings.forcedFiltersMode = 'advanced'; Filter.showFilters('MD5'); return this.close(); }, undo() { Filter.removeFilters('MD5', this.filters); for (var post of this.posts) { if (post.isReply) { PostHiding.show(post); } else if (g.VIEW === 'index') { ThreadHiding.show(post.thread); } } return this.close(); } }, escape(value) { return value.replace(/\/|\\|\^|\$|\n|\.|\(|\)|\{|\}|\[|\]|\?|\*|\+|\|/g, (c) => { if (c === '\n') { return '\\n'; } else { return `\\${c}`; } }); }, menu: { post: undefined, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu'] || !Conf['Filter']) { return; } const div = $.el('div', { textContent: 'Filter' }); const entry = { el: div, order: 50, open(post) { Filter.menu.post = post; return true; }, subEntries: [] }; for (var type of [ ['Name', 'name'], ['Unique ID', 'uniqueID'], ['Tripcode', 'tripcode'], ['Capcode', 'capcode'], ['Pass Date', 'pass'], ['Email', 'email'], ['Subject', 'subject'], ['Comment', 'comment'], ['Flag', 'flag'], ['Filename', 'filename'], ['Image dimensions', 'dimensions'], ['Filesize', 'filesize'], ['Image MD5', 'MD5'] ]) { entry.subEntries.push(Filter.menu.createSubEntry(type[0], type[1])); } return Menu.menu.addEntry(entry); }, createSubEntry(text, type) { const el = $.el('a', { href: 'javascript:;', textContent: text }); el.dataset.type = type; $.on(el, 'click', Filter.menu.makeFilter); return { el, open(post) { return Filter.values(type, post).length; } }; }, makeFilter() { const type = this.dataset.type; const values = Filter.values(type, Filter.menu.post); const res = values.map((value) => { if (['uniqueID', 'MD5'].includes(type)) { return `/${value}/`; } else { return `/^${Filter.escape(value)}$/`; } }).join('\n'); return Filter.addFilter(type, res, () => Filter.showFilters(type)); } } }; var Site = { defaultProperties: { '4chan.org': { software: 'yotsuba' }, '4channel.org': { canonical: '4chan.org' }, '4cdn.org': { canonical: '4chan.org' }, 'notso.smuglo.li': { canonical: 'smuglo.li' }, 'smugloli.net': { canonical: 'smuglo.li' }, 'smug.nepu.moe': { canonical: 'smuglo.li' } }, init(cb) { $.extend(Conf['siteProperties'], Site.defaultProperties); let hostname = Site.resolve(); if (hostname && $.hasOwn(SW, Conf['siteProperties'][hostname].software)) { this.set(hostname); cb(); } $.onExists(doc, 'body', () => { for (var software in SW) { var changes; if (changes = SW[software].detect?.()) { changes.software = software; hostname = location.hostname.replace(/^www\./, ''); var properties = (Conf['siteProperties'][hostname] || (Conf['siteProperties'][hostname] = dict())); var changed = 0; for (var key in changes) { if (properties[key] !== changes[key]) { properties[key] = changes[key]; changed++; } } if (changed) { $.set('siteProperties', Conf['siteProperties']); } if (!g.SITE) { this.set(hostname); cb(); } return; } } }); }, resolve(url = location) { let { hostname } = url; while (hostname && !$.hasOwn(Conf['siteProperties'], hostname)) { hostname = hostname.replace(/^[^.]*\.?/, ''); } if (hostname) { let canonical; if (canonical = Conf['siteProperties'][hostname].canonical) { hostname = canonical; } } return hostname; }, parseURL(url) { const siteID = Site.resolve(url); return Main.parseURL(g.sites[siteID], url); }, set(hostname) { for (var ID in Conf['siteProperties']) { var site; var properties = Conf['siteProperties'][ID]; if (properties.canonical) { continue; } var { software } = properties; if (!software || !$.hasOwn(SW, software)) { continue; } g.sites[ID] = (site = Object.create(SW[software])); $.extend(site, { ID, siteID: ID, properties, software }); } return g.SITE = g.sites[hostname]; } }; var CatalogLinks = { el: null, enabled: false, externalList: null, init() { const site = g.SITE; const board = g.BOARD; if ((site.software === 'yotsuba') && (Conf['External Catalog'] || Conf['JSON Index']) && !(Conf['JSON Index'] && (g.VIEW === 'index'))) { const selector = (() => { switch (g.VIEW) { case 'thread': case 'archive': return '.navLinks.desktop > a'; case 'catalog': return '.navLinks > :first-child > a'; case 'index': return '#ctrl-top > a, .cataloglink > a'; } })(); if (selector) $.ready(function () { for (var link of $$(selector)) { var catalogURL; switch (link.pathname.replace(/\/+/g, '/')) { case `/${board}/`: if (Conf['JSON Index']) { link.textContent = 'Index'; } link.href = CatalogLinks.index(); break; case `/${board}/catalog`: link.href = CatalogLinks.catalog(); break; } if ((g.VIEW === 'catalog') && ((catalogURL = CatalogLinks.catalog()) !== site.urls.catalog?.(board))) { var catalogLink = link.parentNode.cloneNode(true); var link2 = catalogLink.firstElementChild; link2.href = catalogURL; link2.textContent = link2.hostname === location.hostname ? `${meta.name} Catalog` : 'External Catalog'; $.after(link.parentNode, [$.tn(' '), catalogLink]); } } }); } if ((site.software === 'yotsuba') && Conf['JSON Index'] && Conf[`Use ${meta.name} Catalog`]) { Callbacks.Post.push({ name: 'Catalog Link Rewrite', cb: this.node }); } if (this.enabled = Conf['Catalog Links']) { let el; CatalogLinks.el = (el = UI.checkbox('Header catalog links', 'Catalog Links')); el.id = 'toggleCatalog'; const input = $('input', el); $.on(input, 'change', this.toggle); $.sync('Header catalog links', CatalogLinks.set); return Header.menu.addEntry({ el, order: 95 }); } }, node() { for (var a of $$('a', this.nodes.comment)) { var m; if (m = a.href.match(/^https?:\/\/(boards\.4chan(?:nel)?\.org\/[^\/]+)\/catalog(#s=.*)?/)) { a.href = `//${m[1]}/${m[2] || '#catalog'}`; } } }, toggle() { $.event('CloseMenu'); $.set('Header catalog links', this.checked); return CatalogLinks.set(this.checked); }, set(useCatalog) { Conf['Header catalog links'] = useCatalog; CatalogLinks.setLinks(Header.boardList); CatalogLinks.setLinks(Header.bottomBoardList); CatalogLinks.el.title = `Turn catalog links ${useCatalog ? 'off' : 'on'}.`; return $('input', CatalogLinks.el).checked = useCatalog; }, setLinks(list) { if ((!(CatalogLinks.enabled ?? Conf['Catalog Links'])) || !list) { return; } const tail = /(?:index)?(?:\.\w+)?$/; for (var a of $$('a:not([data-only])', list)) { var { siteID, boardID } = a.dataset; if (!siteID || !boardID) { var VIEW; ({ siteID, boardID, VIEW } = Site.parseURL(a)); if (!siteID || !boardID || !['index', 'catalog'].includes(VIEW) || (!a.dataset.indexOptions && (a.href.replace(tail, '') !== (Get.url(VIEW, { siteID, boardID }) || '').replace(tail, '')))) { continue; } $.extend(a.dataset, { siteID, boardID }); } var board = { siteID, boardID }; var url = Conf['Header catalog links'] ? CatalogLinks.catalog(board) : Get.url('index', board); if (url) { a.href = url; if (a.dataset.indexOptions && (url.split('#')[0] === Get.url('index', board))) { a.href += (a.hash ? '/' : '#') + a.dataset.indexOptions; } } } }, externalParse() { CatalogLinks.externalList = dict(); for (var line of Conf['externalCatalogURLs'].split('\n')) { if (line[0] === '#') { continue; } var url = line.split(';')[0]; var boards = Filter.parseBoards(line.match(/;boards:([^;]+)/)?.[1] || '*'); var excludes = Filter.parseBoards(line.match(/;exclude:([^;]+)/)?.[1]) || dict(); for (var board in boards) { if (!excludes[board] && !excludes[board.split('/')[0] + '/*']) { CatalogLinks.externalList[board] = url; } } } }, external({ siteID, boardID }) { if (!CatalogLinks.externalList) { CatalogLinks.externalParse(); } const external = (CatalogLinks.externalList[`${siteID}/${boardID}`] || CatalogLinks.externalList[`${siteID}/*`]); if (external) { return external.replace(/%board/g, boardID); } else { return undefined; } }, jsonIndex(board, hash) { if ((g.SITE.ID === board.siteID) && (g.BOARD.ID === board.boardID) && (g.VIEW === 'index')) { return hash; } else { return Get.url('index', board) + hash; } }, catalog(board = g.BOARD) { let external, nativeCatalog; if (Conf['External Catalog'] && (external = CatalogLinks.external(board))) { return external; } else if (Index.enabledOn(board) && Conf[`Use ${meta.name} Catalog`]) { return CatalogLinks.jsonIndex(board, '#catalog'); } else if (nativeCatalog = Get.url('catalog', board)) { return nativeCatalog; } else { return CatalogLinks.external(board); } }, index(board = g.BOARD) { if (Index.enabledOn(board)) { return CatalogLinks.jsonIndex(board, '#index'); } else { return Get.url('index', board); } } }; const BoardPicker = { trigger: undefined, panel: undefined, list: undefined, search: undefined, tabButtons: {}, tab: 'all', query: '', init() { const trigger = $.el('a', { href: 'javascript:;', className: 'boardpicker-toggle', textContent: g.BOARD ? `/${g.BOARD.ID}/ ▾` : 'Boards ▾', }); BoardPicker.trigger = trigger; $.on(trigger, 'click', BoardPicker.toggle); const panel = (BoardPicker.panel = $.el('div', { id: 'boardpicker' })); const tabs = $.el('div', { className: 'boardpicker-tabs' }); const labels = [['favorites', 'Favorites'], ['all', 'All boards']]; for (const [key, label] of labels) { const btn = $.el('a', { href: 'javascript:;', className: 'boardpicker-tab', textContent: label, }); $.on(btn, 'click', () => BoardPicker.setTab(key)); BoardPicker.tabButtons[key] = btn; $.add(tabs, btn); } const search = (BoardPicker.search = $.el('input', { className: 'boardpicker-search', type: 'search', placeholder: 'Search boards', })); $.on(search, 'input', () => { BoardPicker.query = search.value; BoardPicker.renderList(); }); BoardPicker.list = $.el('div', { className: 'boardpicker-list' }); $.add(panel, [tabs, search, BoardPicker.list]); $.on(d, 'click', BoardPicker.clickAway); $.sync('favoriteBoards', (val) => { Conf['favoriteBoards'] = val; if (BoardPicker.isOpen()) { BoardPicker.render(); } }); BoardConfig.ready(() => { if (BoardPicker.isOpen()) { BoardPicker.render(); } }); }, isOpen() { return $.hasClass(doc, 'xt-boardpicker-open'); }, toggle() { if (BoardPicker.isOpen()) { BoardPicker.close(); } else { BoardPicker.open(); } }, open() { BoardPicker.seedIfNeeded(); BoardPicker.tab = defaultTab(BoardPicker.favorites().length); BoardPicker.query = ''; BoardPicker.search.value = ''; BoardPicker.render(); $.addClass(doc, 'xt-boardpicker-open'); }, close() { $.rmClass(doc, 'xt-boardpicker-open'); }, clickAway(e) { if (!BoardPicker.isOpen()) { return; } const target = e.target; if (!target.isConnected) { return; } if (target.closest('.boardpicker-link') || !target.closest('#boardpicker, #shortcut-boards')) { BoardPicker.close(); } }, favorites() { return parseFavoriteBoards(Conf['favoriteBoards']); }, saveFavorites(codes) { const value = serializeFavoriteBoards(codes); Conf['favoriteBoards'] = value; $.set('favoriteBoards', value); }, seedIfNeeded() { if (Conf['favoriteBoardsSeeded']) { return; } const known = BoardPicker.allBoards(); const current = BoardPicker.favorites(); const seeds = current.length ? current : seedFavoritesFromBoardnav(Conf['boardnav']); const codes = known.length ? seeds.filter(code => known.some(b => b.code === code)) : seeds; BoardPicker.saveFavorites(codes); if (known.length) { Conf['favoriteBoardsSeeded'] = true; $.set('favoriteBoardsSeeded', true); } }, allBoards() { const boards = BoardConfig.boards || Conf['boardConfig'].boards || {}; return Object.keys(boards).sort().map(code => ({ code, title: boards[code].title || '' })); }, setTab(tab) { BoardPicker.tab = tab; BoardPicker.render(); }, render() { for (const key of ['favorites', 'all']) { BoardPicker.tabButtons[key].classList.toggle('active', BoardPicker.tab === key); } BoardPicker.search.hidden = BoardPicker.tab !== 'all'; BoardPicker.renderList(); }, renderList() { const list = BoardPicker.list; $.rmAll(list); const all = BoardPicker.allBoards(); const favorites = BoardPicker.favorites(); let entries; if (BoardPicker.tab === 'favorites') { entries = favorites.map(code => all.find(b => b.code === code) || { code, title: '' }); if (!entries.length) { $.add(list, $.el('div', { className: 'boardpicker-empty', textContent: 'No favorites yet. Star boards in All boards.', })); return; } } else { entries = filterBoards(all, BoardPicker.query); } const frag = $.frag(); for (const entry of entries) { $.add(frag, BoardPicker.buildRow(entry, favorites.includes(entry.code))); } $.add(list, frag); CatalogLinks.setLinks(list); }, buildRow(entry, isFavorite) { const row = $.el('div', { className: 'boardpicker-row' }); if (entry.code === g.BOARD?.ID) { $.addClass(row, 'current'); } const star = $.el('a', { href: 'javascript:;', className: `boardpicker-star${isFavorite ? ' favorited' : ''}`, textContent: isFavorite ? '★' : '☆', }); $.on(star, 'click', (e) => { e.stopPropagation(); BoardPicker.saveFavorites(toggleFavorite(BoardPicker.favorites(), entry.code)); BoardPicker.renderList(); }); const link = $.el('a', { className: 'boardpicker-link', href: `${location.protocol}//${BoardConfig.domain(entry.code)}/${entry.code}/`, textContent: entry.title ? `/${entry.code}/ - ${entry.title}` : `/${entry.code}/`, }); $.add(row, [star, link]); return row; }, }; const PINNED_SHORTCUTS = new Set(['boards', 'menu', 'index-search', 'index-options', 'watch-thread']); var Header = { menu: null, boardList: null, bottomBoardList: null, barFixedToggler: null, scrollHeaderToggler: null, barPositionToggler: null, linkJustifyToggler: null, headerToggler: null, footerToggler: null, bannerToggler: null, shortcutToggler: null, customNavToggler: null, previousOffset: 0, init() { $.onExists(doc, 'body', () => { if (!Main.isThisPageLegit()) { return; } $.add(this.bar, [this.noticesRoot, this.toggle]); $.prepend(d.body, this.bar); $.add(d.body, Header.hover); $.add(d.body, Header.mobileBar); return this.setBarPosition(Conf['Bottom Header']); }); this.menu = new UI.Menu('header'); const mobile = resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice()); this.setBarLayout(Conf['Mobile Bar Layout']); $.sync('Mobile Bar Layout', this.setBarLayout); const menuButton = $.el('span', { className: 'menu-button' }); Icon.set(menuButton, mobile ? 'barsStaggered' : 'caretDown', 'Menu'); const box = UI.checkbox; const barFixedToggler = box('Fixed Header', 'Fixed Header'); const headerToggler = box('Header auto-hide', 'Auto-hide header'); const scrollHeaderToggler = box('Header auto-hide on scroll', 'Auto-hide header on scroll'); const barPositionToggler = box('Bottom Header', 'Bottom header'); const linkJustifyToggler = box('Centered links', 'Centered links'); const customNavToggler = box('Custom Board Navigation', 'Custom board navigation'); const footerToggler = box('Bottom Board List', 'Hide bottom board list'); const bannerToggler = box('Hide Board Banner', 'Hide board banner'); const shortcutToggler = box('Shortcut Icons', 'Shortcut Icons'); const editCustomNav = $.el('a', { textContent: 'Edit custom board navigation', href: 'javascript:;' }); this.barFixedToggler = barFixedToggler.firstElementChild; this.scrollHeaderToggler = scrollHeaderToggler.firstElementChild; this.barPositionToggler = barPositionToggler.firstElementChild; this.linkJustifyToggler = linkJustifyToggler.firstElementChild; this.headerToggler = headerToggler.firstElementChild; this.footerToggler = footerToggler.firstElementChild; this.bannerToggler = bannerToggler.firstElementChild; this.shortcutToggler = shortcutToggler.firstElementChild; this.customNavToggler = customNavToggler.firstElementChild; $.on(menuButton, 'click', this.menuToggle); $.on(this.headerToggler, 'change', this.toggleBarVisibility); $.on(this.barFixedToggler, 'change', this.toggleBarFixed); $.on(this.barPositionToggler, 'change', this.toggleBarPosition); $.on(this.scrollHeaderToggler, 'change', this.toggleHideBarOnScroll); $.on(this.linkJustifyToggler, 'change', this.toggleLinkJustify); $.on(this.footerToggler, 'change', this.toggleFooterVisibility); $.on(this.bannerToggler, 'change', this.toggleBannerVisibility); $.on(this.shortcutToggler, 'change', this.toggleShortcutIcons); $.on(this.customNavToggler, 'change', this.toggleCustomNav); $.on(editCustomNav, 'click', this.editCustomNav); this.setBarFixed(Conf['Fixed Header']); this.setHideBarOnScroll(Conf['Header auto-hide on scroll'] || mobile); this.setBarVisibility(Conf['Header auto-hide']); this.setLinkJustify(Conf['Centered links']); this.setShortcutIcons(Conf['Shortcut Icons']); this.setFooterVisibility(Conf['Bottom Board List']); this.setBannerVisibility(Conf['Hide Board Banner']); $.sync('Fixed Header', this.setBarFixed); $.sync('Header auto-hide on scroll', this.setHideBarOnScroll); $.sync('Bottom Header', this.setBarPosition); $.sync('Shortcut Icons', this.setShortcutIcons); $.sync('Header auto-hide', this.setBarVisibility); $.sync('Centered links', this.setLinkJustify); $.sync('Bottom Board List', this.setFooterVisibility); $.sync('Hide Board Banner', this.setBannerVisibility); this.addShortcut('menu', menuButton, 900); if (mobile && g.SITE?.software === 'yotsuba') { BoardPicker.init(); this.addShortcut('boards', BoardPicker.trigger, 0); $.add(this.bar, BoardPicker.panel); if (g.VIEW === 'thread') { const catalogLink = $.el('a', { href: CatalogLinks.catalog(), title: 'Catalog' }); Icon.set(catalogLink, 'grid', 'Catalog'); this.addShortcut('catalog', catalogLink, 5); } } this.menu.addEntry({ el: $.el('span', { textContent: 'Header' }), order: 107, subEntries: [ { el: barFixedToggler }, { el: headerToggler }, { el: scrollHeaderToggler }, { el: barPositionToggler }, { el: linkJustifyToggler }, { el: footerToggler }, { el: bannerToggler }, { el: shortcutToggler }, { el: customNavToggler }, { el: editCustomNav } ] }); const highlightNextToggle = $.el('label', { title: 'Mark settings added by neXT. Green = new, orange = changed.', innerHTML: ' Highlight neXT' }); const highlightNextInput = $('input', highlightNextToggle); $.on(highlightNextToggle, 'click', (e) => e.stopPropagation()); $.on(highlightNextInput, 'change', () => Settings.setHighlightNext(highlightNextInput.checked)); this.menu.addEntry({ el: highlightNextToggle, order: 800, open() { highlightNextInput.checked = Settings.highlightNext; return true; } }); $.on(d, 'CreateNotification', this.createNotification); this.setBoardList(); $.onExists(doc, `${g.SITE.selectors.boardList} + *`, Header.generateFullBoardList); Main.ready(function () { let footer; if ((g.SITE.software === 'yotsuba') && !(footer = $.id('boardNavDesktopFoot'))) { let absbot; if (!(absbot = $.id('absbot'))) { return; } footer = $.id('boardNavDesktop').cloneNode(true); footer.id = 'boardNavDesktopFoot'; $('#navtopright', footer).id = 'navbotright'; $('#settingsWindowLink', footer).id = 'settingsWindowLinkBot'; $.before(absbot, footer); $.global('stubCloneTopNav'); } if (Header.bottomBoardList = $(g.SITE.selectors.boardListBottom)) { for (var a of $$('a', Header.bottomBoardList)) { if ((a.hostname === location.hostname) && (a.pathname.split('/')[1] === g.BOARD.ID)) { a.className = 'current'; } } return CatalogLinks.setLinks(Header.bottomBoardList); } }); if ((g.SITE.software === 'yotsuba') && ((g.VIEW === 'catalog') || !Conf['Disable Native Extension'])) { const cs = $.el('a', { href: 'javascript:;' }); if (g.VIEW === 'catalog') { cs.title = (cs.textContent = 'Catalog Settings'); Icon.set(cs, 'bookOpen', 'Catalog Settings'); this.addShortcut('native', cs, 810); } else { cs.title = (cs.textContent = '4chan Settings'); cs.className = 'native-settings'; this.addShortcut('native', cs, 810); } $.on(cs, 'click', () => $.id('settingsWindowLink').click()); } return this.enableDesktopNotifications(); }, bar: $.el('div', { id: 'header-bar' }), noticesRoot: $.el('div', { id: 'notifications' }), shortcuts: $.el('span', { id: 'shortcuts' }), mobileBar: $.el('div', { id: 'mobile-bar', className: 'dialog' }), splitActive: false, hover: $.el('div', { id: 'hoverUI' }), toggle: $.el('div', { id: 'scroll-marker' }), setBoardList() { let boardList; Header.boardList = (boardList = $.el('span', { id: 'board-list' })); $.extend(boardList, { innerHTML: "" }); const btn = $('.hide-board-list-button', boardList); $.on(btn, 'click', Header.toggleBoardList); $.prepend(Header.bar, [Header.boardList, Header.shortcuts]); Header.setCustomNav(Conf['Custom Board Navigation']); Header.generateBoardList(Conf['boardnav']); $.sync('Custom Board Navigation', Header.setCustomNav); return $.sync('boardnav', Header.generateBoardList); }, generateFullBoardList() { let nodes; const transformBoardList = g.SITE.transformBoardList; if (transformBoardList) { nodes = transformBoardList(); } else { nodes = [...$(g.SITE.selectors.boardList).cloneNode(true).childNodes]; } const fullBoardList = $('.boardList', Header.boardList); $.add(fullBoardList, nodes); for (var a of $$('a', fullBoardList)) { if ((a.hostname === location.hostname) && (a.pathname.split('/')[1] === g.BOARD.ID)) { a.className = 'current'; } } return CatalogLinks.setLinks(fullBoardList); }, generateBoardList(boardnav) { const list = $('#custom-board-list', Header.boardList); $.rmAll(list); if (!boardnav) return; boardnav = boardnav.replace(/(\r\n|\n|\r)/g, ' '); const segments = boardnav.split(/(\{\{(?:"[^"]+")?|\}\})/); const spanStack = []; let currentContainer = list; segments.forEach(segment => { if (segment.startsWith('{{')) { const span = $.el('span'); $.add(currentContainer, span); spanStack.push(span); currentContainer = span; if (segment.length > 2) span.className = segment.slice(3, -1); } else if (segment === '}}') { spanStack.pop(); currentContainer = spanStack.length > 0 ? spanStack[spanStack.length - 1] : list; } else { segment = Header.expandNavGroups(segment); const re = /[\w@]+(-(all|title|replace|full|index|catalog|archive|expired|nt|(mode|sort|text):"[^"]+"(,"[^"]+")?))*|[^\w@]+/g; const segmentNodes = (segment.match(re) || []).map((t) => Header.mapCustomNavigation(t)); segmentNodes.forEach(node => currentContainer.appendChild(node)); } }); return CatalogLinks.setLinks(list); }, expandNavGroups(segment) { const MODS = 'title|replace|full|index|catalog|archive|expired|nt'; const groupRe = new RegExp(`((?:\\b(?:${MODS})\\b[ \\t]*)+)\\{([^{}]*)\\}`, 'g'); const tokenRe = /[\w@]+(-(all|title|replace|full|index|catalog|archive|expired|nt|(mode|sort|text):"[^"]+"(,"[^"]+")?))*|[^\w@]+/g; return segment.replace(groupRe, (_m, modWords, inner) => { const suffix = '-' + modWords.trim().split(/\s+/).join('-'); return inner.replace(tokenRe, (tok) => /^[\w@]/.test(tok) && !tok.includes('-') ? tok + suffix : tok); }); }, mapCustomNavigation(t) { let a, href, m; let url; if (/^[^\w@]/.test(t)) { return $.tn(t); } let text; text = (url = null); t = t.replace(/-text:"([^"]+)"(?:,"([^"]+)")?/g, function (m0, m1, m2) { text = m1; url = m2; return ''; }); let indexOptions = []; t = t.replace(/-(?:mode|sort):"([^"]+)"/g, function (m0, m1) { indexOptions.push(m1.toLowerCase().replace(/\ /g, '-')); return ''; }); indexOptions = indexOptions.join('/'); if (/^toggle-all/.test(t)) { a = $.el('a', { className: 'show-board-list-button', textContent: text || '+', href: 'javascript:;' }); $.on(a, 'click', Header.toggleBoardList); return a; } if (/^external/.test(t)) { a = $.el('a', { href: url || 'javascript:;', textContent: text || '+', className: 'external' }); if (/-nt/.test(t)) { a.target = '_blank'; a.rel = 'noopener'; } return a; } let boardID = t.split('-')[0]; if (boardID === 'current') { if (['boards.4chan.org', 'boards.4channel.org'].includes(location.hostname)) { boardID = g.BOARD.ID; } else { a = $.el('a', { href: `/${g.BOARD.ID}/`, textContent: text || decodeURIComponent(g.BOARD.ID), className: 'current' }); if (/-nt/.test(t)) { a.target = '_blank'; a.rel = 'noopener'; } if (/-index/.test(t)) { a.dataset.only = 'index'; } else if (/-catalog/.test(t)) { a.dataset.only = 'catalog'; a.href += 'catalog.html'; } else if (/-(archive|expired)/.test(t)) { a = a.firstChild; } return a; } } a = (function () { let urlV; if (boardID === '@') { return $.el('a', { href: 'https://twitter.com/4chan', title: '4chan Twitter', className: 'navSmall', textContent: '@' }); } a = $.el('a', { href: `//${BoardConfig.domain(boardID)}/${boardID}/`, textContent: boardID, title: BoardConfig.title(boardID) }); if ((g.VIEW === 'catalog' || g.VIEW === 'archive') && (urlV = Get.url(g.VIEW, { siteID: '4chan.org', boardID }))) { a.href = urlV; } if ((a.hostname === location.hostname) && (boardID === g.BOARD.ID)) { a.className = 'current'; } return a; })(); a.textContent = /-title/.test(t) || (/-replace/.test(t) && (a.hostname === location.hostname) && (boardID === g.BOARD.ID)) ? a.title || a.textContent : /-full/.test(t) ? (`/${boardID}/`) + (a.title ? ` - ${a.title}` : '') : text || boardID; if (m = t.match(/-(index|catalog)/)) { const urlIC = CatalogLinks[m[1]]({ siteID: '4chan.org', boardID }); if (urlIC) { a.dataset.only = m[1]; a.href = urlIC; if (m[1] === 'catalog') { $.addClass(a, 'catalog'); } } else { return a.firstChild; } } if (Conf['JSON Index'] && indexOptions) { a.dataset.indexOptions = indexOptions; if (['boards.4chan.org', 'boards.4channel.org'].includes(a.hostname) && (a.pathname.split('/')[2] === '')) { a.href += (a.hash ? '/' : '#') + indexOptions; } } if (/-archive/.test(t)) { if (href = Redirect.to('board', { boardID })) { a.href = href; } else { return a.firstChild; } } if (/-expired/.test(t)) { if (BoardConfig.isArchived(boardID)) { a.href = `//${BoardConfig.domain(boardID)}/${boardID}/archive`; } else { return a.firstChild; } } if (/-nt/.test(t)) { a.target = '_blank'; a.rel = 'noopener'; } return a; }, toggleBoardList() { const { bar } = Header; const custom = $('#custom-board-list', bar); const full = $('#full-board-list', bar); const showBoardList = !full.hidden; custom.hidden = !showBoardList; return full.hidden = showBoardList; }, setLinkJustify(centered) { Header.linkJustifyToggler.checked = centered; if (centered) { return $.addClass(doc, 'centered-links'); } else { return $.rmClass(doc, 'centered-links'); } }, toggleLinkJustify() { $.event('CloseMenu'); const centered = this.nodeName === 'INPUT' ? this.checked : undefined; Header.setLinkJustify(centered); return $.set('Centered links', centered); }, setBarFixed(fixed) { Header.barFixedToggler.checked = fixed; if (fixed) { $.addClass(doc, 'fixed'); return $.addClass(Header.bar, 'dialog'); } else { $.rmClass(doc, 'fixed'); return $.rmClass(Header.bar, 'dialog'); } }, toggleBarFixed() { $.event('CloseMenu'); Header.setBarFixed(this.checked); Conf['Fixed Header'] = this.checked; return $.set('Fixed Header', this.checked); }, setShortcutIcons(show) { Header.shortcutToggler.checked = show; if (show) { return $.addClass(doc, 'shortcut-icons'); } else { return $.rmClass(doc, 'shortcut-icons'); } }, toggleShortcutIcons() { $.event('CloseMenu'); Header.setShortcutIcons(this.checked); Conf['Shortcut Icons'] = this.checked; return $.set('Shortcut Icons', this.checked); }, setBarVisibility(hide) { Header.headerToggler.checked = hide; $.event('CloseMenu'); (hide ? $.addClass : $.rmClass)(Header.bar, 'autohide'); return (hide ? $.addClass : $.rmClass)(doc, 'autohide'); }, toggleBarVisibility() { const hide = this.nodeName === 'INPUT' ? this.checked : !$.hasClass(Header.bar, 'autohide'); Conf['Header auto-hide'] = hide; $.set('Header auto-hide', hide); Header.setBarVisibility(hide); const message = `The header bar will ${hide ? 'automatically hide itself.' : 'remain visible.'}`; return new Notice('info', message, 2); }, setHideBarOnScroll(hide) { Header.scrollHeaderToggler.checked = hide; if (hide) { $.on(window, 'scroll', Header.hideBarOnScroll); return; } $.off(window, 'scroll', Header.hideBarOnScroll); $.rmClass(Header.bar, 'scroll'); return Header.bar.classList.toggle('autohide', Conf['Header auto-hide']); }, toggleHideBarOnScroll() { const hide = this.checked; $.cb.checked.call(this); return Header.setHideBarOnScroll(hide); }, hideBarOnScroll() { if ($.hasClass(doc, 'xt-boardpicker-open') || $.hasClass(doc, 'xt-index-search-open')) { return; } const offsetY = window.pageYOffset; if (offsetY > (Header.previousOffset || 0)) { $.addClass(Header.bar, 'autohide', 'scroll'); } else { $.rmClass(Header.bar, 'autohide', 'scroll'); } return Header.previousOffset = offsetY; }, setBarPosition(bottom) { if (Header.barPositionToggler) Header.barPositionToggler.checked = bottom; $.event('CloseMenu'); const args = bottom ? [ 'bottom-header', 'top-header', 'after' ] : [ 'top-header', 'bottom-header', 'add' ]; $.addClass(doc, args[0]); $.rmClass(doc, args[1]); return $[args[2]](Header.bar, Header.noticesRoot); }, toggleBarPosition() { $.cb.checked.call(this); return Header.setBarPosition(this.checked); }, setFooterVisibility(hide) { Header.footerToggler.checked = hide; return doc.classList.toggle('hide-bottom-board-list', hide); }, setBannerVisibility(hide) { Header.bannerToggler.checked = hide; return doc.classList.toggle('hide-board-banner', hide); }, toggleBannerVisibility() { Header.setBannerVisibility(this.checked); return $.set('Hide Board Banner', this.checked); }, toggleFooterVisibility() { $.event('CloseMenu'); const hide = this.nodeName === 'INPUT' ? this.checked : $.hasClass(doc, 'hide-bottom-board-list'); Header.setFooterVisibility(hide); $.set('Bottom Board List', hide); const message = hide ? 'The bottom navigation will now be hidden.' : 'The bottom navigation will remain visible.'; return new Notice('info', message, 2); }, setCustomNav(show) { Header.customNavToggler.checked = show; const cust = $('#custom-board-list', Header.bar); const full = $('#full-board-list', Header.bar); const btn = $('.hide-board-list-container', full); return [cust.hidden, full.hidden, btn.hidden] = show ? [false, true, false] : [true, false, true]; }, toggleCustomNav() { $.cb.checked.call(this); return Header.setCustomNav(this.checked); }, editCustomNav() { Settings.open('Interface'); const settings = $.id('fourchanx-settings'); return $('[name=boardnav]', settings).focus(); }, scrollTo(root, down = false, needed = false) { let height, x; if (!root.offsetParent) { return; } if (down) { x = Header.getBottomOf(root); if (Conf['Fixed Header'] && Conf['Header auto-hide on scroll'] && Conf['Bottom header']) { ({ height } = Header.bar.getBoundingClientRect()); if (x <= 0) { if (!Header.isHidden()) { x += height; } } else { if (Header.isHidden()) { x -= height; } } } if (!needed || (x < 0)) { return window.scrollBy(0, -x); } } else { x = Header.getTopOf(root); if (Conf['Fixed Header'] && Conf['Header auto-hide on scroll'] && !Conf['Bottom header']) { ({ height } = Header.bar.getBoundingClientRect()); if (x >= 0) { if (!Header.isHidden()) { x += height; } } else { if (Header.isHidden()) { x -= height; } } } if (!needed || (x < 0)) { return window.scrollBy(0, x); } } }, scrollToIfNeeded(root, down) { return Header.scrollTo(root, down, true); }, getTopOf(root) { let { top } = root.getBoundingClientRect(); if (Conf['Fixed Header'] && !Conf['Bottom Header']) { const headRect = Header.toggle.getBoundingClientRect(); top -= headRect.top + headRect.height; } return top; }, getBottomOf(root) { const { clientHeight } = doc; let bottom = clientHeight - root.getBoundingClientRect().bottom; if (Conf['Fixed Header'] && Conf['Bottom Header']) { const headRect = Header.toggle.getBoundingClientRect(); bottom -= (clientHeight - headRect.bottom) + headRect.height; } return bottom; }, isNodeVisible(node) { if (d.hidden || !doc.contains(node)) { return false; } const { height } = node.getBoundingClientRect(); return ((Header.getTopOf(node) + height) >= 0) && ((Header.getBottomOf(node) + height) >= 0); }, isHidden() { const { top } = Header.bar.getBoundingClientRect(); if (Conf['Bottom header']) { return top === doc.clientHeight; } else { return top < 0; } }, setBarLayout(setting) { const layout = resolveBarLayout(setting); if (layout !== setting && Conf['Mobile Bar Layout'] === setting) { Conf['Mobile Bar Layout'] = layout; $.set('Mobile Bar Layout', layout); } const mobile = resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice()); applyBarLayout(layout, doc, mobile); Header.splitActive = mobile && iconsInMobileBar(); const [from, to] = Header.splitActive ? [Header.shortcuts, Header.mobileBar] : [Header.mobileBar, Header.shortcuts]; for (var shortcut of $$('.shortcut', from)) { if (Header.splitActive && PINNED_SHORTCUTS.has(shortcut.id.replace('shortcut-', ''))) { continue; } Header.insertShortcut(shortcut, to); } }, insertShortcut(shortcut, container) { for (var item of $$('[data-index]', container)) { if (+item.dataset.index > +shortcut.dataset.index) { $.before(item, shortcut); return; } } return $.add(container, shortcut); }, addShortcut(id, el, index) { const shortcut = $.el('span', { id: `shortcut-${id}`, className: 'shortcut brackets-wrap' }); $.add(shortcut, el); shortcut.dataset.index = index.toString(); const container = Header.splitActive && !PINNED_SHORTCUTS.has(id) ? Header.mobileBar : Header.shortcuts; return Header.insertShortcut(shortcut, container); }, rmShortcut(el) { return $.rm(el.parentElement); }, menuToggle(e) { return Header.menu.toggle(e, this, g); }, createNotification(e) { const { type, content, lifetime } = e.detail; return new Notice(type, content, lifetime); }, areNotificationsEnabled: false, enableDesktopNotifications() { let notice; if (!window.Notification || !Conf['Desktop Notifications']) { return; } switch (Notification.permission) { case 'granted': Header.areNotificationsEnabled = true; return; case 'denied': return; } const el = $.el('span', { innerHTML: `${meta.name} needs your permission to show desktop notifications. ` + `[FAQ]` + `
    or ` }); const [authorize, disable] = $$('button', el); $.on(authorize, 'click', () => Notification.requestPermission(function (status) { Header.areNotificationsEnabled = status === 'granted'; if (status === 'default') { return; } return notice.close(); })); $.on(disable, 'click', function () { $.set('Desktop Notifications', false); return notice.close(); }); return notice = new Notice('info', el); } }; class Notice { constructor(type, content, timeout, onclose) { this.add = this.add.bind(this); this.close = this.close.bind(this); this.timeout = timeout; this.onclose = onclose; this.el = $.el('div', { innerHTML: `${Icon.get('xmark')}
    ` }); this.el.style.opacity = '0'; this.setType(type); $.on(this.el.firstElementChild, 'click', this.close); if (typeof content === 'string') { content = $.tn(content); } $.add(this.el.lastElementChild, content); $.ready(this.add); } setType(type) { this.el.className = `notification ${type}`; } add() { if (this.closed) return; if (d.hidden) { $.on(d, 'visibilitychange', this.add); return; } $.off(d, 'visibilitychange', this.add); $.add(Header.noticesRoot, this.el); this.el.clientHeight; this.el.style.opacity = '1'; if (this.timeout) { this.timeoutId = setTimeout(this.close, this.timeout * SECOND); } } close() { if (this.timeoutId) clearTimeout(this.timeoutId); this.closed = true; $.off(d, 'visibilitychange', this.add); $.rm(this.el); this.onclose?.(); } resetTimer() { if (this.timeout) { clearTimeout(this.timeoutId); this.timeoutId = setTimeout(this.close, this.timeout * SECOND); } } } var archives = [{ "uid": 3, "name": "4plebs", "domain": "archive.4plebs.org", "http": true, "https": true, "software": "foolfuuka", "boards": ["adv", "f", "hr", "mlpol", "mo", "o", "pol", "s4s", "sp", "tg", "trv", "tv", "x"], "files": ["adv", "f", "hr", "mlpol", "mo", "o", "pol", "s4s", "sp", "tg", "trv", "tv", "x"], "reports": true }, { "uid": 10, "name": "warosu", "domain": "warosu.org", "http": false, "https": true, "software": "fuuka", "boards": ["3", "biz", "cgl", "ck", "diy", "fa", "ic", "jp", "lit", "sci", "vr", "vt"], "files": ["3", "biz", "cgl", "ck", "diy", "fa", "ic", "jp", "lit", "sci", "vr", "vt"], "search": ["biz", "cgl", "ck", "diy", "fa", "ic", "jp", "lit", "sci", "vr", "vt"] }, { "uid": 23, "name": "Desuarchive", "domain": "desuarchive.org", "http": true, "https": true, "software": "foolfuuka", "boards": ["a", "aco", "an", "c", "cgl", "co", "d", "fit", "g", "his", "int", "k", "m", "mlp", "mu", "q", "qa", "r9k", "tg", "trash", "vr", "wsg"], "files": ["a", "aco", "an", "c", "cgl", "co", "d", "fit", "g", "his", "int", "k", "m", "mlp", "mu", "q", "qa", "r9k", "tg", "trash", "vr"], "reports": true }, { "uid": 24, "name": "fireden.net", "domain": "boards.fireden.net", "http": false, "https": true, "software": "foolfuuka", "boards": ["cm", "co", "ic", "sci", "vip", "y"], "files": ["cm", "co", "ic", "sci", "vip", "y"], "search": ["cm", "co", "ic", "sci", "y"] }, { "uid": 25, "name": "not arch.b4k.co", "domain": "arch.b4k.dev", "http": true, "https": true, "software": "foolfuuka", "boards": ["g", "mlp", "qb", "v", "vg", "vm", "vmg", "vp", "vrpg", "vst"], "files": ["qb", "v", "vg", "vm", "vmg", "vp", "vrpg", "vst"], "search": ["qb", "v", "vg", "vm", "vmg", "vp", "vrpg", "vst"] }, { "uid": 29, "name": "Archived.Moe", "domain": "archived.moe", "http": true, "https": true, "software": "foolfuuka", "boards": ["3", "a", "aco", "adv", "an", "asp", "b", "bant", "biz", "c", "can", "cgl", "ck", "cm", "co", "cock", "con", "d", "diy", "e", "f", "fa", "fap", "fit", "fitlit", "g", "gd", "gif", "h", "hc", "his", "hm", "hr", "i", "ic", "int", "jp", "k", "lgbt", "lit", "m", "mlp", "mlpol", "mo", "mtv", "mu", "n", "news", "o", "out", "outsoc", "p", "po", "pol", "pw", "q", "qa", "qb", "qst", "r", "r9k", "s", "s4s", "sci", "soc", "sp", "spa", "t", "tg", "toy", "trash", "trv", "tv", "u", "v", "vg", "vint", "vip", "vm", "vmg", "vp", "vr", "vrpg", "vst", "vt", "w", "wg", "wsg", "wsr", "x", "xs", "y"], "files": ["can", "cock", "con", "fap", "fitlit", "gd", "mlpol", "mo", "mtv", "outsoc", "po", "q", "qb", "qst", "spa", "vint", "vip"], "search": ["aco", "adv", "an", "asp", "b", "bant", "biz", "c", "can", "cgl", "ck", "cm", "cock", "con", "d", "diy", "e", "f", "fap", "fitlit", "gd", "gif", "h", "hc", "his", "hm", "hr", "i", "ic", "lgbt", "lit", "mlpol", "mo", "mtv", "n", "news", "o", "out", "outsoc", "p", "po", "pw", "q", "qa", "qst", "r", "s", "soc", "spa", "trv", "u", "vint", "vip", "vrpg", "w", "wg", "wsg", "wsr", "x", "y"], "reports": true }, { "uid": 30, "name": "TheBArchive.com", "domain": "thebarchive.com", "http": true, "https": true, "software": "foolfuuka", "boards": ["b", "bant"], "files": ["b", "bant"], "reports": true }, { "uid": 31, "name": "Archive Of Sins", "domain": "archiveofsins.com", "http": true, "https": true, "software": "foolfuuka", "boards": ["h", "hc", "hm", "i", "lgbt", "r", "s", "soc", "t", "u"], "files": ["h", "hc", "hm", "i", "lgbt", "r", "s", "soc", "t", "u"], "reports": true }, { "uid": 36, "name": "palanq.win", "domain": "archive.palanq.win", "http": false, "https": true, "software": "foolfuuka", "boards": ["bant", "c", "con", "e", "i", "n", "news", "out", "p", "pw", "qst", "toy", "vip", "vp", "vt", "w", "wg", "wsr"], "files": ["bant", "c", "e", "i", "n", "news", "out", "p", "pw", "qst", "toy", "vip", "vp", "vt", "w", "wg", "wsr"], "reports": true }, { "uid": 37, "name": "Eientei", "domain": "eientei.xyz", "http": false, "https": true, "software": "Eientei", "boards": ["3", "i", "sci", "xs"], "files": ["3", "i", "sci", "xs"], "reports": true }] ; var Redirect = { archives, data: null, init() { this.selectArchives(); if (Conf['archiveAutoUpdate']) { const now = Date.now(); if (now - (2 * DAY) >= Conf['lastarchivecheck'] || Conf['lastarchivecheck'] > now) this.update(); } }, selectArchives() { const o = { thread: new Map(), threadJSON: new Map(), post: new Map(), file: new Map(), }; const archives = dict(); for (const data of Conf['archives']) { for (var key of ['boards', 'files']) { if (!(data[key] instanceof Array)) { data[key] = []; } } const { uid, name, boards, files, software } = data; if (!['fuuka', 'foolfuuka'].includes(software)) { continue; } archives[JSON.stringify(uid ?? name)] = data; for (const boardID of boards) { if (!o.thread.has(boardID)) o.thread.set(boardID, data); if (!o.file.has(boardID) && files.includes(boardID)) o.file.set(boardID, data); if (software === 'foolfuuka') { if (!o.threadJSON.has(boardID)) o.threadJSON.set(boardID, data); if (!o.post.has(boardID)) o.post.set(boardID, data); } } } for (const boardID in Conf['selectedArchives']) { var record = Conf['selectedArchives'][boardID]; for (const [type, id] of Object.entries(record)) { var archive; if ((archive = archives[JSON.stringify(id)]) && $.hasOwn(o, type)) { const boards = type === 'file' ? archive.files : archive.boards; if (boards.includes(boardID)) { o[type].set(boardID, archive); } } } } Redirect.data = o; }, update(cb) { let url; const urls = []; const responses = []; let nloaded = 0; for (url of Conf['archiveLists'].split('\n')) { if (url[0] !== '#') { url = url.trim(); if (url) { urls.push(url); } } } const fail = (url, action, msg) => new Notice('warning', `Error ${action} archive data from\n${url}\n${msg}`, 20); const load = (i) => (function () { if (this.status !== 200) { return fail(urls[i], 'fetching', (this.status ? `Error ${this.statusText} (${this.status})` : 'Connection Error')); } let { response } = this; if (!(response instanceof Array)) { response = [response]; } responses[i] = response; nloaded++; if (nloaded === urls.length) { return Redirect.parse(responses, cb); } }); if (urls.length) { for (let i = 0; i < urls.length; i++) { url = urls[i]; if (['[', '{'].includes(url[0])) { var response; try { response = JSON.parse(url); } catch (err) { fail(url, 'parsing', err instanceof Error ? err.message : String(err)); continue; } load(i).call({ status: 200, response }); } else { CrossOrigin.ajax(url, { onloadend: load(i) }); } } } else { Redirect.parse([], cb); } }, parse(responses, cb) { const archives = []; const archiveUIDs = dict(); for (var response of responses) { for (var data of response) { var uid = JSON.stringify(data.uid ?? data.name); if (uid in archiveUIDs) { $.extend(archiveUIDs[uid], data); } else { archiveUIDs[uid] = dict.clone(data); archives.push(data); } } } const items = { archives, lastarchivecheck: Date.now() }; $.set(items); $.extend(Conf, items); Redirect.selectArchives(); return cb?.(); }, to(dest, data) { const archive = (['search', 'board'].includes(dest) ? Redirect.data.thread : Redirect.data[dest]).get(data.boardID); if (!archive) { return ''; } return Redirect[dest](archive, data); }, protocol(archive) { let { protocol } = location; if (!$.getOwn(archive, protocol.slice(0, -1))) { protocol = protocol === 'https:' ? 'http:' : 'https:'; } return `${protocol}//`; }, thread(archive, { boardID, threadID, postID }) { let path = threadID ? `${boardID}/thread/${threadID}` : `${boardID}/post/${postID}`; if (archive.software === 'foolfuuka') { path += '/'; } if (threadID && postID) { path += archive.software === 'foolfuuka' ? `#${postID}` : `#p${postID}`; } return `${Redirect.protocol(archive)}${archive.domain}/${path}`; }, threadJSON(archive, { boardID, threadID }) { return `${Redirect.protocol(archive)}${archive.domain}/_/api/chan/thread/?board=${boardID}&num=${threadID}`; }, post(archive, { boardID, postID }) { const protocol = Redirect.protocol(archive); const url = `${protocol}${archive.domain}/_/api/chan/post/?board=${boardID}&num=${postID}`; if (!Redirect.securityCheck(url)) { return ''; } return url; }, file(archive, { boardID, filename }) { if (!filename) { return ''; } if (boardID === 'f') { filename = encodeURIComponent($.unescape(decodeURIComponent(filename))); } else { if (/[sm]\.jpg$/.test(filename)) { return ''; } } if (archive.name.endsWith('arch.b4k.co') || archive.name.endsWith('palanq.win')) { const [timeStamp, ext] = filename.split('.'); if (timeStamp.length > 13) { filename = `${timeStamp.slice(0, -3)}.${ext}`; } } return `${Redirect.protocol(archive)}${archive.domain}/${boardID}/full_image/${filename}`; }, board(archive, { boardID }) { return `${Redirect.protocol(archive)}${archive.domain}/${boardID}/`; }, search(archive, { boardID, type, value }) { type = type === 'name' ? 'username' : type === 'MD5' ? 'image' : type; if (type === 'capcode') { value = $.getOwn({ 'Developer': 'dev', 'Verified': 'ver' }, value) || value.toLowerCase(); } else if (type === 'image') { value = value.replace(/[+/=]/g, (c) => ({ '+': '-', '/': '_', '=': '' }[c])); } value = encodeURIComponent(value); const path = archive.software === 'foolfuuka' ? `${boardID}/search/${type}/${value}/` : type === 'image' ? `${boardID}/image/${value}` : `${boardID}/?task=search2&search_${type}=${value}`; return `${Redirect.protocol(archive)}${archive.domain}/${path}`; }, report(boardID) { const urls = []; for (var archive of Conf['archives']) { var { software, https, reports, boards, name, domain } = archive; if ((software === 'foolfuuka') && https && reports && boards instanceof Array && boards.includes(boardID)) { urls.push([name, `https://${domain}/_/api/chan/offsite_report/`]); } } return urls; }, securityCheck(url) { return /^https:\/\//.test(url) || (location.protocol === 'http:') || Conf['Exempt Archives from Encryption']; }, navigate(dest, data, alternative) { if (!Redirect.data) { Redirect.init(); } const url = Redirect.to(dest, data); if (url && (Redirect.securityCheck(url) || confirm(`Redirect to ${url}?\n\nYour connection will not be encrypted.`))) { return location.replace(url); } else if (alternative) { return location.replace(alternative); } } }; class CatalogThreadNative { toString() { return this.ID; } constructor(root) { this.nodes = { root, thumb: $(g.SITE.selectors.catalog.thumb, root) }; this.siteID = g.SITE.ID; this.boardID = this.nodes.thumb.parentNode.pathname.split(/\/+/)[1]; this.board = g.boards[this.boardID] || new Board(this.boardID); this.ID = (this.threadID = +(root.dataset.id || root.id).match(/\d*$/)[0]); this.thread = this.board.threads.get(String(this.ID)) || new Thread(String(this.ID), this.board); } } const Anonymize = { init() { if (!Conf['Anonymize']) { return; } return $.addClass(doc, 'anonymize'); } }; var ImageHover = { init() { if (g.VIEW !== 'index' && g.VIEW !== 'thread') { return; } if (Conf['Image Hover']) { Callbacks.Post.push({ name: 'Image Hover', cb: this.node }); } if (Conf['Image Hover in Catalog']) { return Callbacks.CatalogThread.push({ name: 'Image Hover', cb: this.catalogNode }); } }, node() { return this.files.filter((file) => (file.isImage || file.isVideo) && file.thumb).map((file) => $.on(file.thumb, 'mouseover', ImageHover.mouseover(this, file))); }, catalogNode() { const file = this.thread.OP.files[0]; if (!file || (!file.isImage && !file.isVideo)) { return; } return $.on(this.nodes.thumb, 'mouseover', ImageHover.mouseover(this.thread.OP, file)); }, mouseover(post, file) { return function (e) { let el, height, width; if (!doc.contains(this)) { return; } const { isVideo } = file; if (file.isExpanding || file.isExpanded || g.SITE.isThumbExpanded?.(file)) { return; } const error = ImageHover.error(post, file); if (ImageCommon.cache?.dataset.fileID === `${post.fullID}.${file.index}`) { el = ImageCommon.popCache(); $.on(el, 'error', error); } else { el = $.el((isVideo ? 'video' : 'img')); el.dataset.fileID = `${post.fullID}.${file.index}`; $.on(el, 'error', error); el.src = file.url; } if (Conf['Restart when Opened']) { ImageCommon.rewind(el); ImageCommon.rewind(this); } el.id = 'ihover'; $.add(Header.hover, el); if (isVideo) { el.loop = true; el.controls = false; Volume.setup(el); if (Conf['Autoplay']) { el.play(); if (this.nodeName === 'VIDEO') { this.currentTime = el.currentTime; } } } if (file.dimensions) { [width, height] = file.dimensions.split('x').map((x) => +x); const maxWidth = doc.clientWidth; const maxHeight = doc.clientHeight - UI.hover.padding; const scale = Math.min(1, maxWidth / width, maxHeight / height); width *= scale; height *= scale; el.style.maxWidth = `${width}px`; el.style.maxHeight = `${height}px`; } return UI.hover({ root: this, el, latestEvent: e, endEvents: 'mouseout click', height, width, noRemove: true, cb() { $.off(el, 'error', error); ImageCommon.pushCache(el); ImageCommon.pause(el); $.rm(el); return el.removeAttribute('style'); } }); }; }, error(post, file) { return function () { if (ImageCommon.decodeError(this, file)) { return; } return ImageCommon.error(this, post, file, 3 * SECOND, URL => { if (URL) { return this.src = URL + (this.src === URL ? '?' + Date.now() : ''); } else { return $.rm(this); } }); }; } }; var ImageLoader = { prefetchEnabled: false, init() { if (g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive') { return; } const replace = Conf['Replace Thumbnails'] && (Conf['Replace JPG'] || Conf['Replace PNG'] || Conf['Replace GIF'] || Conf['Replace WEBM']); if (!Conf['Image Prefetching'] && !replace) { return; } Callbacks.Post.push({ name: 'Image Replace', cb: this.node }); $.on(d, 'PostsInserted', function () { if (ImageLoader.prefetchEnabled || replace) { return g.posts.forEach(ImageLoader.prefetchAll); } }); if (Conf['Replace Thumbnails'] && Conf['Replace WEBM']) { $.on(d, 'scroll visibilitychange 4chanXInitFinished PostsInserted', this.playVideos); } if (!Conf['Image Prefetching'] || (g.VIEW !== 'index' && g.VIEW !== 'thread')) { return; } const el = $.el('a', { href: 'javascript:;', title: 'Prefetch Images', className: 'disabled', }); Icon.set(el, 'bolt', 'Prefetch'); $.on(el, 'click', this.toggle); return Header.addShortcut('prefetch', el, 525); }, node() { if (this.isClone) { return; } for (var file of this.files) { if (Conf['Replace Thumbnails'] && Conf['Replace WEBM'] && file.isVideo) { ImageLoader.replaceVideo(this, file); } ImageLoader.prefetch(this, file); } }, replaceVideo(post, file) { const { thumb } = file; const video = $.el('video', { preload: 'none', loop: true, muted: true, poster: thumb.src || thumb.dataset.src, textContent: thumb.alt, className: thumb.className }); video.setAttribute('muted', 'muted'); video.dataset.md5 = thumb.dataset.md5; for (var attr of ['height', 'width', 'maxHeight', 'maxWidth']) { video.style[attr] = thumb.style[attr]; } video.src = file.url; $.replace(thumb, video); file.thumb = video; return file.videoThumb = true; }, prefetch(post, file) { let clone, type; const { isImage, isVideo, thumb, url } = file; if (file.isPrefetched || !(isImage || isVideo) || post.isHidden || post.thread.isHidden) { return; } if (isVideo) { type = 'WEBM'; } else { type = url.match(/\.([^.]+)$/)?.[1].toUpperCase(); if (type === 'JPEG') { type = 'JPG'; } } const replace = Conf['Replace Thumbnails'] && Conf[`Replace ${type}`] && !/spoiler/.test(thumb.src || thumb.dataset.src); if (!replace && !ImageLoader.prefetchEnabled) { return; } if ($.hasClass(doc, 'catalog-mode')) { return; } if (![post, ...post.clones].some(clone => doc.contains(clone.nodes.root))) { return; } file.isPrefetched = true; if (file.videoThumb) { for (clone of post.clones) { clone.file.thumb.preload = 'auto'; } thumb.preload = 'auto'; if ($.engine === 'gecko') { $.on(thumb, 'loadeddata', function () { return this.removeAttribute('poster'); }); } return; } const el = $.el(isImage ? 'img' : 'video'); if (isVideo) { el.preload = 'auto'; } if (replace && isImage) { $.on(el, 'load', function () { for (clone of post.clones) { clone.file.thumb.src = url; } return thumb.src = url; }); } return el.src = url; }, prefetchAll(post) { for (var file of post.files) { ImageLoader.prefetch(post, file); } }, toggle() { ImageLoader.prefetchEnabled = !ImageLoader.prefetchEnabled; this.classList.toggle('disabled', !ImageLoader.prefetchEnabled); if (ImageLoader.prefetchEnabled) { g.posts.forEach(ImageLoader.prefetchAll); } }, playVideos() { const qpClone = $.id('qp')?.firstElementChild; return g.posts.forEach(function (post) { for (post of [post, ...post.clones]) { for (var file of post.files) { if (file.videoThumb) { var { thumb } = file; if (Header.isNodeVisible(thumb) || (post.nodes.root === qpClone)) { thumb.play(); } else { thumb.pause(); } } } } }); } }; const VideoDuration = { cache: new Map(), observer: undefined, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive') || !Conf['Video Duration Badge']) { return; } if (window.IntersectionObserver) { VideoDuration.observer = new IntersectionObserver(VideoDuration.onIntersect, { rootMargin: '200px' }); } return Callbacks.Post.push({ name: 'Video Duration Badge', cb: VideoDuration.node, }); }, node() { const { file } = this; if (!file || !file.isVideo || !file.thumbLink) { return; } const thumbLink = file.thumbLink; const cached = file.MD5 ? VideoDuration.cache.get(file.MD5) : undefined; if (cached) { VideoDuration.addBadge(thumbLink, cached); return; } if (VideoDuration.observer) { thumbLink._vdFile = file; VideoDuration.observer.observe(thumbLink); } else { VideoDuration.probe(thumbLink, file); } }, onIntersect(entries, observer) { for (const entry of entries) { if (!entry.isIntersecting) { continue; } const thumbLink = entry.target; observer.unobserve(thumbLink); const file = thumbLink._vdFile; delete thumbLink._vdFile; if (file) { VideoDuration.probe(thumbLink, file); } } }, probe(thumbLink, file) { const md5 = file.MD5; if (md5 && VideoDuration.cache.has(md5)) { VideoDuration.addBadge(thumbLink, VideoDuration.cache.get(md5)); return; } const apply = (seconds) => { const text = VideoDuration.format(seconds); if (md5) { VideoDuration.cache.set(md5, text); } VideoDuration.addBadge(thumbLink, text); }; if (/\.webm$/i.test(file.url)) { CrossOrigin.binary(file.url, (data) => { let seconds = null; if (data) { try { seconds = VideoDuration.parseWebmDuration(data); } catch { } } if (seconds != null && isFinite(seconds) && seconds > 0) { apply(seconds); } else { VideoDuration.probeElement(file, apply); } }, { Range: 'bytes=0-16383' }); return; } VideoDuration.probeElement(file, apply); }, probeElement(file, apply) { const video = $.el('video', { preload: 'metadata' }); const cleanup = () => { video.removeAttribute('src'); video.load(); }; $.on(video, 'loadedmetadata', () => { const seconds = video.duration; if (isFinite(seconds) && seconds > 0) { apply(seconds); } cleanup(); }); $.on(video, 'error', cleanup); video.src = file.url; }, parseWebmDuration(data) { let i = 0; const readVint = () => { let n = data[i++]; let len = 0; while (n < (0x80 >> len)) { len++; } n ^= (0x80 >> len); while (len-- && (i < data.length)) { n = (n << 8) ^ data[i++]; } return n; }; let timecodeScale = 1000000; let durationTicks = null; while (i < data.length) { const element = readVint(); const size = readVint(); if (element === 0x8538067 || element === 0x549A966) { continue; } else if (element === 0xAD7B1) { let v = 0; for (let k = 0; (k < size) && (i < data.length); k++) { v = (v * 256) + data[i++]; } timecodeScale = v; } else if (element === 0x489) { if (((size === 4) || (size === 8)) && ((i + size) <= data.length)) { const dv = new DataView(data.buffer, data.byteOffset + i, size); durationTicks = (size === 4) ? dv.getFloat32(0, false) : dv.getFloat64(0, false); } i += size; } else { i += size; } } if (durationTicks == null) { return null; } return (durationTicks * timecodeScale) / 1e9; }, addBadge(thumbLink, text) { if ($('.video-duration-badge', thumbLink)) { return; } $.addClass(thumbLink, 'has-video-duration'); $.add(thumbLink, $.el('span', { className: 'video-duration-badge', textContent: text })); }, format(seconds) { seconds = Math.round(seconds); const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; const ss = String(s).padStart(2, '0'); return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`; }, }; const RevealSpoilers = { init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive') || !Conf['Reveal Spoiler Thumbnails']) { return; } return Callbacks.Post.push({ name: 'Reveal Spoiler Thumbnails', cb: this.node }); }, node() { if (this.isClone) { return; } for (var file of this.files) { if (file.thumb && file.isSpoiler) { var { thumb } = file; thumb.removeAttribute('style'); thumb.style.maxHeight = (thumb.style.maxWidth = this.isReply ? '125px' : '250px'); if (thumb.src) { thumb.src = file.thumbURL; } else { thumb.dataset.src = file.thumbURL; } } } } }; const ArchiveLink = { init() { const site = g.SITE; if (!site || (site.software !== 'yotsuba') || (g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu'] || !Conf['Archive Link']) { return; } const div = $.el('div', { textContent: 'Archive' }); const entry = { el: div, order: 60, open({ ID, thread, board }) { return !!Redirect.to('thread', { postID: ID, threadID: thread.ID, boardID: board.ID }); }, subEntries: [] }; for (var type of [ ['Post', 'post'], ['Name', 'name'], ['Tripcode', 'tripcode'], ['Capcode', 'capcode'], ['Subject', 'subject'], ['Flag', 'country'], ['Filename', 'filename'], ['Image MD5', 'MD5'] ]) { entry.subEntries.push(this.createSubEntry(type[0], type[1])); } return Menu.menu.addEntry(entry); }, createSubEntry(text, type) { const el = $.el('a', { textContent: text, target: '_blank' }); const open = type === 'post' ? function ({ ID, thread, board }) { el.href = Redirect.to('thread', { postID: ID, threadID: thread.ID, boardID: board.ID }); return true; } : function (post) { const typeParam = (type === 'country') && post.info.flagCodeTroll ? 'troll_country' : type; const value = type === 'country' ? post.info.flagCode || post.info.flagCodeTroll?.toLowerCase() : Filter.values(type, post)[0]; if (!value) { return false; } el.href = Redirect.to('search', { boardID: post.board.ID, type: typeParam, value, isSearch: true }); return true; }; return { el, open }; } }; var CopyTextLink = { text: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu'] || !Conf['Copy Text Link']) { return; } const a = $.el('a', { className: 'copy-text-link', href: 'javascript:;', textContent: 'Copy Text' }); $.on(a, 'click', CopyTextLink.copy); return Menu.menu.addEntry({ el: a, order: 12, open(post) { CopyTextLink.text = (post.origin || post).commentOrig(); return !doc.classList.contains('xt-mobile'); } }); }, copy() { const el = $.el('textarea', { className: 'copy-text-element', value: CopyTextLink.text }); $.add(d.body, el); el.select(); try { d.execCommand('copy'); } catch (error) { } return $.rm(el); } }; var DeleteLink = { auto: [dict(), dict()], post: null, nodes: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu'] || !Conf['Delete Link']) { return; } const div = $.el('div', { className: 'delete-link', textContent: 'Delete' }); const postEl = $.el('a', { className: 'delete-post', href: 'javascript:;' }); const fileEl = $.el('a', { className: 'delete-file', href: 'javascript:;' }); this.nodes = { menu: div.firstChild, links: [postEl, fileEl] }; const postEntry = { el: postEl, open() { postEl.textContent = DeleteLink.linkText(false); $.on(postEl, 'click', DeleteLink.toggle); return true; } }; const fileEntry = { el: fileEl, open({ file }) { if (!file || file.isDead) { return false; } fileEl.textContent = DeleteLink.linkText(true); $.on(fileEl, 'click', DeleteLink.toggle); return true; } }; return Menu.menu.addEntry({ el: div, order: 40, open(post) { if (post.isDead) { return false; } DeleteLink.post = post; DeleteLink.nodes.menu.textContent = DeleteLink.menuText(); DeleteLink.cooldown.start(post); return true; }, subEntries: [postEntry, fileEntry] }); }, menuText() { let seconds; if ((seconds = DeleteLink.cooldown.seconds[DeleteLink.post.fullID])) { return `Delete (${seconds})`; } else { return 'Delete'; } }, linkText(fileOnly) { let text = fileOnly ? 'File' : 'Post'; if (DeleteLink.auto[+fileOnly][DeleteLink.post.fullID]) { text = `Deleting ${text.toLowerCase()}...`; } return text; }, toggle() { const { post } = DeleteLink; const fileOnly = $.hasClass(this, 'delete-file'); const auto = DeleteLink.auto[+fileOnly]; if (auto[post.fullID]) { delete auto[post.fullID]; } else { auto[post.fullID] = true; } this.textContent = DeleteLink.linkText(fileOnly); if (!DeleteLink.cooldown.seconds[post.fullID]) { return DeleteLink.delete(post, fileOnly); } }, delete(post, fileOnly) { const link = DeleteLink.nodes.links[+fileOnly]; delete DeleteLink.auto[+fileOnly][post.fullID]; if (post.fullID === DeleteLink.post.fullID) { $.off(link, 'click', DeleteLink.toggle); } const form = { mode: 'usrdel', onlyimgdel: fileOnly, pwd: QR.persona.getPassword() }; form[+post.ID] = 'delete'; return $.ajax($.id('delform').action.replace(`/${g.BOARD}/`, `/${post.board}/`), { responseType: 'document', withCredentials: true, onloadend() { return DeleteLink.load(link, post, fileOnly, this.response); }, form: $.formData(form) }); }, load(link, post, fileOnly, resDoc) { let msg; if (!resDoc) { new Notice('warning', 'Connection error, please retry.', 20); if (post.fullID === DeleteLink.post.fullID) { $.on(link, 'click', DeleteLink.toggle); } return; } link.textContent = DeleteLink.linkText(fileOnly); if (resDoc.title === '4chan - Banned') { const el = $.el('span', { innerHTML: "You can't delete posts because you are banned." }); return new Notice('warning', el, 20); } else if (msg = resDoc.getElementById('errmsg')) { new Notice('warning', msg.textContent, 20); if (post.fullID === DeleteLink.post.fullID) { $.on(link, 'click', DeleteLink.toggle); } if (QR.cooldown.data && Conf['Cooldown'] && /\bwait\b/i.test(msg.textContent)) { DeleteLink.cooldown.start(post, 5); DeleteLink.auto[+fileOnly][post.fullID] = true; return DeleteLink.nodes.links[+fileOnly].textContent = DeleteLink.linkText(fileOnly); } } else { if (!fileOnly) { QR.cooldown.delete(post); } if (resDoc.title === 'Updating index...') { (post.origin || post).kill(fileOnly); } if (post.fullID === DeleteLink.post.fullID) { return link.textContent = 'Deleted'; } } }, cooldown: { seconds: dict(), start(post, seconds) { if (DeleteLink.cooldown.seconds[post.fullID] != null) { return; } if (seconds == null) { seconds = QR.cooldown.secondsDeletion(post); } if (seconds > 0) { DeleteLink.cooldown.seconds[post.fullID] = seconds; return DeleteLink.cooldown.count(post); } }, count(post) { if (post.fullID === DeleteLink.post.fullID) { DeleteLink.nodes.menu.textContent = DeleteLink.menuText(); } if ((DeleteLink.cooldown.seconds[post.fullID] > 0) && Conf['Cooldown']) { DeleteLink.cooldown.seconds[post.fullID]--; setTimeout(DeleteLink.cooldown.count, 1000, post); } else { delete DeleteLink.cooldown.seconds[post.fullID]; for (var fileOnly of [false, true]) { if (DeleteLink.auto[+fileOnly][post.fullID]) { DeleteLink.delete(post, fileOnly); } } } } } }; const DownloadLink = { init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu']) { return; } const mobile = resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice()); if (!Conf['Download Link'] && !mobile) { return; } const a = $.el('a', { className: 'download-link', textContent: 'Download file' }); $.on(a, 'click', ImageCommon.download); return Menu.menu.addEntry({ el: a, order: 100, open({ file }) { if (!file) { return false; } a.href = file.url; a.download = file.name; if (mobile) { const details = file.dimensions ? `${file.size}, ${file.dimensions}` : file.size; a.textContent = `Download file (${details})`; } return true; } }); } }; const FileNameLink = { init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu']) { return; } if (!resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { return; } const a = $.el('a', { className: 'filename-link', target: '_blank' }); return Menu.menu.addEntry({ el: a, order: 99, open({ file }) { if (!file) { return false; } a.href = file.url; a.textContent = file.name; return true; } }); } }; var ReplyLink = { post: undefined, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu'] || !Conf['Quick Reply']) { return; } const reply = $.el('a', { className: 'reply-link', href: 'javascript:;', textContent: 'Reply' }); $.on(reply, 'click', ReplyLink.reply); return Menu.menu.addEntry({ el: reply, order: 6, open(post) { ReplyLink.post = post.origin || post; return doc.classList.contains('xt-mobile'); } }); }, reply() { const { post } = ReplyLink; if (!post) { return; } $.event('CloseMenu'); return QR.quote.call(post.nodes.post, undefined); } }; var ReportLink = { url: null, dims: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu'] || !Conf['Report Link']) { return; } const a = $.el('a', { className: 'report-link', href: 'javascript:;', textContent: 'Report' }); $.on(a, 'click', ReportLink.report); return Menu.menu.addEntry({ el: a, order: 10, open(post) { ReportLink.url = `//sys.${location.hostname.split('.')[1]}.org/${post.board}/imgboard.php?mode=report&no=${post}`; if (d.cookie.indexOf('pass_enabled=1') >= 0) { ReportLink.dims = 'width=350,height=275'; } else { ReportLink.dims = 'width=400,height=550'; } return true; } }); }, report() { const { url, dims } = ReportLink; const id = Date.now(); const set = `toolbar=0,scrollbars=1,location=0,status=1,menubar=0,resizable=1,${dims}`; return window.open(url, String(id), set); } }; var SelectTextLink = { post: undefined, dialog: undefined, backdrop: undefined, body: undefined, selection: '', init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu']) { return; } const a = $.el('a', { className: 'select-text-link', href: 'javascript:;', textContent: 'Select Text' }); $.on(a, 'click', SelectTextLink.openDialog); return Menu.menu.addEntry({ el: a, order: 8, open(post) { SelectTextLink.post = post.origin || post; return doc.classList.contains('xt-mobile'); } }); }, openDialog() { const { post } = SelectTextLink; if (!post) { return; } $.event('CloseMenu'); SelectTextLink.close(); const backdrop = (SelectTextLink.backdrop = $.el('div', { id: 'select-text-backdrop' })); const dialog = (SelectTextLink.dialog = $.el('div', { id: 'select-text', className: 'dialog' })); const header = $.el('div', { className: 'select-text-header' }); const title = $.el('span', { className: 'select-text-title', textContent: `Select text from >>${post.ID}` }); const closeBtn = $.el('a', { href: 'javascript:;', className: 'select-text-close' }); Icon.set(closeBtn, 'xmark'); $.on(closeBtn, 'click', (e) => { e.preventDefault(); SelectTextLink.close(); }); $.add(header, [title, closeBtn]); const body = (SelectTextLink.body = $.el('div', { className: 'select-text-body' })); const clone = post.nodes.comment.cloneNode(true); for (const inline of [...clone.querySelectorAll('.inline')]) { $.rm(inline); } $.add(body, clone); const actions = $.el('div', { className: 'select-text-actions' }); const copyBtn = $.el('a', { href: 'javascript:;', className: 'select-text-copy', textContent: 'Copy' }); $.on(copyBtn, 'click', SelectTextLink.copy); $.add(actions, copyBtn); if (Conf['Quick Reply']) { const quoteBtn = $.el('a', { href: 'javascript:;', className: 'select-text-quote', textContent: 'Quote in QR' }); $.on(quoteBtn, 'click', SelectTextLink.quote); $.add(actions, quoteBtn); } $.add(dialog, [header, body, actions]); $.on(backdrop, 'click', SelectTextLink.close); SelectTextLink.selection = ''; $.on(d, 'selectionchange', SelectTextLink.trackSelection); $.add(d.body, [backdrop, dialog]); }, trackSelection() { const { body } = SelectTextLink; const sel = d.getSelection(); if (!body || !sel || sel.isCollapsed || !sel.anchorNode) { return; } if (!body.contains(sel.anchorNode)) { return; } SelectTextLink.selection = sel.toString(); }, grabText() { const { body, post } = SelectTextLink; const sel = d.getSelection(); if (sel && !sel.isCollapsed && sel.anchorNode && body?.contains(sel.anchorNode)) { return sel.toString(); } return SelectTextLink.selection || post?.commentOrig() || ''; }, copy() { const text = SelectTextLink.grabText(); SelectTextLink.close(); if (!text) { return; } const el = $.el('textarea', { value: text }); $.add(d.body, el); el.select(); try { d.execCommand('copy'); } catch (error) { } return $.rm(el); }, quote() { const { post } = SelectTextLink; const text = SelectTextLink.grabText(); SelectTextLink.close(); if (!post || !QR.postingIsEnabled) { return; } const ref = post.board.ID === g.BOARD.ID ? `>>${post}` : `>>>/${post.board}/${post}`; let quoted = `${ref}\n`; if (text.trim()) { quoted += text.trim().split('\n').map(line => `>${line}`).join('\n') + '\n'; } QR.openPost(); const { com, thread } = QR.nodes; if (!com.value) { thread.value = Get.threadFromNode(post.nodes.post); } const wasOnlyQuotes = QR.selected.isOnlyQuotes(); const caretPos = com.selectionStart; com.value = com.value.slice(0, caretPos) + quoted + com.value.slice(com.selectionEnd); const range = caretPos + quoted.length; com.setSelectionRange(range, range); com.focus(); if (wasOnlyQuotes) { QR.selected.quotedText = com.value; } QR.selected.save(com); return QR.selected.save(thread); }, close() { const { dialog, backdrop } = SelectTextLink; if (!dialog) { return; } $.off(d, 'selectionchange', SelectTextLink.trackSelection); $.rm(dialog); if (backdrop) { $.rm(backdrop); } SelectTextLink.dialog = undefined; SelectTextLink.backdrop = undefined; SelectTextLink.body = undefined; SelectTextLink.selection = ''; } }; const SoundLink = { init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Menu']) return; if (!Conf['Thread Updater']) return; SoundManager.init(); SoundLink.addPostEntry(); }, addPostEntry() { const el = $.el('a', { className: 'sound-link sound-link-post', href: 'javascript:;', textContent: 'Sound (You-post)', }); const entry = { el, order: 60, open(post) { if (!QuoteYou.isYou(post)) return false; const target = { boardID: post.boardID, threadID: post.threadID, postID: post.ID }; const current = SoundManager.getPostOverride(target); entry.subEntries = SoundLink.buildSubEntries('post', target, current); return true; }, subEntries: [], }; Menu.menu.addEntry(entry); }, buildSubEntries(scope, target, currentSoundId) { const subs = []; let order = 10; subs.push(SoundLink.makeOptionEntry({ label: 'None (inherit)', soundId: null, isCurrent: currentSoundId == null, scope, target, order: order++, })); for (const lib of SoundManager.library()) { subs.push(SoundLink.makeOptionEntry({ label: lib.name, soundId: lib.id, isCurrent: lib.id === currentSoundId, scope, target, order: order++, })); } subs.push(SoundLink.makeManageEntry(order++)); return subs; }, makeOptionEntry({ label, soundId, isCurrent, scope, target, order, }) { const a = $.el('a', { href: 'javascript:;', textContent: (isCurrent ? '✓ ' : ' ') + label, className: 'entry sound-option' + (isCurrent ? ' current' : ''), }); a.style.order = String(order); $.on(a, 'click', () => { SoundLink.applySelection(scope, target, soundId); if (soundId) { const entry = SoundManager.getEntry(soundId); if (entry?.data) ThreadUpdater.playSound(entry.data, false); } $.event('CloseMenu', null); }); return { el: a }; }, makeManageEntry(order) { const a = $.el('a', { href: 'javascript:;', textContent: 'Manage sounds…', className: 'entry sound-manage', }); a.style.order = String(order); $.on(a, 'click', () => { Settings.open('Advanced'); $.event('CloseMenu', null); }); return { el: a }; }, applySelection(scope, target, soundId) { switch (scope) { case 'post': SoundManager.setPostOverride(target, soundId); break; case 'board': SoundManager.setBoardOverride(target.boardID, soundId); break; } }, }; var AntiAutoplay = { init() { if (!Conf['Disable Autoplaying Sounds']) { return; } $.addClass(doc, 'anti-autoplay'); for (var audio of $$('audio[autoplay]', doc)) { this.stop(audio); } window.addEventListener('loadstart', (e => this.stop(e.target)), true); Callbacks.Post.push({ name: 'Disable Autoplaying Sounds', cb: this.node }); return $.ready(() => this.process(d.body)); }, stop(audio) { if (!audio.autoplay) { return; } audio.pause(); audio.autoplay = false; if (audio.controls) { return; } audio.controls = true; return $.addClass(audio, 'controls-added'); }, node() { return AntiAutoplay.process(this.nodes.comment); }, process(root) { for (var iframe of $$('iframe[src*="youtube"][src*="autoplay=1"]', root)) { AntiAutoplay.processVideo(iframe, 'src'); } for (var object of $$('object[data*="youtube"][data*="autoplay=1"]', root)) { AntiAutoplay.processVideo(object, 'data'); } }, processVideo(el, attr) { el[attr] = el[attr].replace(/\?autoplay=1&?/, '?').replace('&autoplay=1', ''); if (window.getComputedStyle(el).display === 'none') { el.style.display = 'block'; } return $.addClass(el, 'autoplay-removed'); } }; var Banner = { db: null, choices: null, init() { if (Conf['Custom Board Titles']) { this.db = new DataBoard('customTitles', undefined, true); } $.asap((() => d.body), () => $.asap((() => $('hr')), Banner.ready)); if (g.BOARD.ID !== 'f') { return Main.ready(() => $.queueTask(Banner.load)); } }, ready() { const banner = $(".boardBanner"); const { children } = banner; if ((g.VIEW === 'thread') && Conf['Remove Thread Excerpt']) { Banner.setTitle(children[1].textContent); } children[0].title = "Click to change"; $.on(children[0], 'click', Banner.cb.toggle); if (Conf['Custom Board Titles']) { Banner.custom(children[1]); if (children[2]) { return Banner.custom(children[2]); } } }, load() { const bannerCnt = $.id('bannerCnt'); if (!bannerCnt.firstChild) { const img = $.el('img', { alt: '4chan', src: '//s.4cdn.org/image/title/' + bannerCnt.dataset.src }); return $.add(bannerCnt, img); } }, setTitle(title) { if (Unread.title != null) { Unread.title = title; return Unread.update(); } else { return d.title = title; } }, cb: { toggle() { if (!Banner.choices?.length) { Banner.choices = Conf['knownBanners'].split(',').slice(); } const choices = Banner.choices; const i = Math.floor(choices.length * Math.random()); const banner = choices.splice(i, 1); return $('img', this.parentNode).src = `//s.4cdn.org/image/title/${banner}`; }, click(e) { if (!e.ctrlKey && !e.metaKey) { return; } if (Banner.original[this.className] == null) { Banner.original[this.className] = this.cloneNode(true); } this.contentEditable = true; for (var br of $$('br', this)) { $.replace(br, $.tn('\n')); } return this.focus(); }, keydown(e) { e.stopPropagation(); if (!e.shiftKey && (e.keyCode === 13)) { return this.blur(); } }, blur() { for (var br of $$('br', this)) { $.replace(br, $.tn('\n')); } if (this.textContent = this.textContent.replace(/\n*$/, '')) { this.contentEditable = false; return Banner.db.set({ boardID: g.BOARD.ID, threadID: this.className, val: { title: this.textContent, orig: Banner.original[this.className].textContent } }); } else { $.rmAll(this); $.add(this, [...Banner.original[this.className].cloneNode(true).childNodes]); return Banner.db.delete({ boardID: g.BOARD.ID, threadID: this.className }); } } }, original: dict(), custom(child) { let data; const { className } = child; child.title = `Ctrl/\u2318+click to edit board ${className.slice(5).toLowerCase()}`; child.spellcheck = false; for (var event of ['click', 'keydown', 'blur']) { $.on(child, event, Banner.cb[event]); } if (data = Banner.db.get({ boardID: g.BOARD.ID, threadID: className })) { if (Conf['Persistent Custom Board Titles'] || (data.orig === child.textContent)) { Banner.original[className] = child.cloneNode(true); return child.textContent = data.title; } else { return Banner.db.delete({ boardID: g.BOARD.ID, threadID: className }); } } } }; var Flash = { init() { if ((g.BOARD.ID === 'f') && Conf['Enable Native Flash Embedding']) { return $.ready(Flash.initReady); } }, initReady() { if ($.hasStorage) { $.global('initFlash'); } else { if (g.VIEW === 'thread') { $.global('setThreadId'); } $.global('initFlashNoStorage'); } } }; var Fourchan = { init() { if ((g.SITE.software !== 'yotsuba') || (g.VIEW !== 'index' && g.VIEW !== 'thread' && g.VIEW !== 'archive')) { return; } BoardConfig.ready(this.initBoard); return Main.ready(this.initReady); }, initBoard() { Fourchan.applyPageFeatureFlags(); if (g.BOARD.config.code_tags) { $.on(window, 'prettyprint:cb', function (e) { let post, pre; if (!(post = g.posts.get(e.detail.ID))) { return; } if (!(pre = $$('.prettyprint', post.nodes.comment)[+e.detail.i])) { return; } if (!$.hasClass(pre, 'prettyprinted')) { pre.innerHTML = e.detail.html; return $.addClass(pre, 'prettyprinted'); } }); $.global('fourChanPrettyPrintListener'); Callbacks.Post.push({ name: 'Parse [code] tags', cb: Fourchan.code }); g.posts.forEach(function (post) { if (post.callbacksExecuted) { return Callbacks.Post.execute(post, ['Parse [code] tags'], true); } }); ExpandComment.callbacks.push(Fourchan.code); } if (g.BOARD.config.math_tags) { $.global('fourChanMathjaxListener'); Callbacks.Post.push({ name: 'Parse [math] tags', cb: Fourchan.math }); g.posts.forEach(function (post) { if (post.callbacksExecuted) { return Callbacks.Post.execute(post, ['Parse [math] tags'], true); } }); return ExpandComment.callbacks.push(Fourchan.math); } }, applyPageFeatureFlags() { const flags = ['code_tags', 'math_tags', 'sjis_tags']; for (const script of $$('script')) { const text = script.textContent || ''; for (const flag of flags) { if (!g.BOARD.config[flag] && new RegExp(`\\b${flag}\\s*=\\s*true\\b`).test(text)) { g.BOARD.config[flag] = 1; } } } }, initReady() { return $.global('disable4chanIdHl'); }, code() { if (this.isClone) { return; } return $.ready(() => { const iterable = $$('.prettyprint', this.nodes.comment); for (let i = 0; i < iterable.length; i++) { var pre = iterable[i]; if (!$.hasClass(pre, 'prettyprinted')) { $.event('prettyprint', { ID: this.fullID, i, html: pre.innerHTML }, window); } } }); }, math() { let wbrs; if (!/\[(math|eqn)\]/.test(this.nodes.comment.textContent)) { return; } if ((wbrs = $$('wbr', this.nodes.comment)).length) { for (var wbr of wbrs) { $.rm(wbr); } this.nodes.comment.normalize(); } var cb = () => { if (!doc.contains(this.nodes.comment)) { return; } $.off(d, 'PostsInserted', cb); return $.event('mathjax', null, this.nodes.comment); }; $.on(d, 'PostsInserted', cb); return cb(); } }; var IDColor = { ids: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Color User IDs']) { return; } this.ids = dict(); this.ids['Heaven'] = [0, 0, 0, '#fff']; return Callbacks.Post.push({ name: 'Color User IDs', cb: this.node }); }, node() { let span, uid; if (this.isClone || !((uid = this.info.uniqueID) && (span = this.nodes.uniqueID))) { return; } const rgb = IDColor.ids[uid] || IDColor.compute(uid); const { style } = span; style.color = rgb[3]; style.backgroundColor = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; return $.addClass(span, 'painted'); }, compute(uid) { const hash = g.SITE.uidColor ? g.SITE.uidColor(uid) : parseInt(uid, 16); const rgb = [ (hash >> 16) & 0xFF, (hash >> 8) & 0xFF, hash & 0xFF ]; rgb.push($.luma(rgb) > 125 ? '#000' : '#fff'); return this.ids[uid] = rgb; } }; var IDHighlight = { init() { if (g.VIEW !== 'index' && g.VIEW !== 'thread') { return; } return Callbacks.Post.push({ name: 'Highlight by User ID', cb: this.node }); }, uniqueID: null, node() { if (this.nodes.uniqueIDRoot) { $.on(this.nodes.uniqueIDRoot, 'click', IDHighlight.click(this)); } if (this.nodes.capcode) { $.on(this.nodes.capcode, 'click', IDHighlight.click(this)); } if (!this.isClone) { return IDHighlight.set(this); } }, set(post) { const match = (post.info.uniqueID || post.info.capcode) === IDHighlight.uniqueID; return $[match ? 'addClass' : 'rmClass'](post.nodes.post, 'highlight'); }, click(post) { return function () { const uniqueID = post.info.uniqueID || post.info.capcode; IDHighlight.uniqueID = IDHighlight.uniqueID === uniqueID ? null : uniqueID; return g.posts.forEach(IDHighlight.set); }; } }; var IDPostCount = { thread: null, init() { if ((g.VIEW !== 'thread') || !Conf['Count Posts by ID']) { return; } Callbacks.Thread.push({ name: 'Count Posts by ID', cb() { return IDPostCount.thread = this; } }); return Callbacks.Post.push({ name: 'Count Posts by ID', cb: this.node }); }, node() { if (this.nodes.uniqueID && (this.thread === IDPostCount.thread)) { return $.on(this.nodes.uniqueID, 'mouseover', IDPostCount.count); } }, count() { const post = Get.postFromNode(this); if (!post) { return; } const { uniqueID } = post.info; let n = 0; IDPostCount.thread.posts.forEach(function (post) { if (post.info.uniqueID === uniqueID) { return n++; } }); return this.title = `${n} post${n === 1 ? '' : 's'} by this ID`; } }; var QuoteInline = { init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Quote Inlining']) { return; } if (Conf['Comment Expansion']) { ExpandComment.callbacks.push(this.node); } return Callbacks.Post.push({ name: 'Quote Inlining', cb: this.node }); }, node() { const { process } = QuoteInline; const { isClone } = this; for (var link of this.nodes.quotelinks.concat([...this.nodes.backlinks], this.nodes.archivelinks)) { process(link, isClone); } }, process(link, clone) { if (Conf['Quote Hash Navigation']) { if (!clone) { $.after(link, QuoteInline.qiQuote(link, $.hasClass(link, 'filtered'))); } } return $.on(link, 'click', QuoteInline.toggle); }, qiQuote(link, hidden) { let name = "hashlink"; if (hidden) { name += " filtered"; } return $.el('a', { className: name, textContent: '#', href: link.href }); }, toggle(e) { if ($.modifiedClick(e)) { return; } const { boardID, threadID, postID } = Get.postDataFromLink(this); if (Conf['Inline Cross-thread Quotes Only'] && (g.VIEW === 'thread') && g.posts.get(`${boardID}.${postID}`)?.nodes.root.offsetParent) { return; } if ($.hasClass(doc, 'catalog-mode')) { return; } e.preventDefault(); const quoter = Get.postFromNode(this); if (!quoter) { return; } const { context } = quoter; if ($.hasClass(this, 'inlined')) { QuoteInline.rm(this, boardID, threadID, postID, context); } else { if ($.x(`ancestor::div[@data-full-i-d='${boardID}.${postID}']`, this)) { return; } QuoteInline.add(this, boardID, threadID, postID, context, quoter); } return this.classList.toggle('inlined'); }, findRoot(quotelink, isBacklink) { if (isBacklink) { return $.x('ancestor::*[parent::*[contains(@class,"post")]][1]', quotelink); } else { return $.x('ancestor-or-self::*[parent::blockquote][1]', quotelink); } }, add(quotelink, boardID, threadID, postID, context, quoter) { let post; const isBacklink = $.hasClass(quotelink, 'backlink'); const inline = $.el('div', { className: 'inline' }); inline.dataset.fullID = `${boardID}.${postID}`; const root = QuoteInline.findRoot(quotelink, isBacklink); $.after(root, inline); const qroot = $.x('ancestor::*[contains(@class,"postContainer")][1]', root); $.addClass(qroot, 'hasInline'); new Fetcher(boardID, threadID, postID, inline, quoter); if (!((post = g.posts.get(`${boardID}.${postID}`)) && (context.thread === post.thread))) { return; } if (isBacklink && Conf['Forward Hiding']) { $.addClass(post.nodes.root, 'forwarded'); post.forwarded++ || (post.forwarded = 1); } if (!Unread.posts) { return; } return Unread.readSinglePost(post); }, rm(quotelink, boardID, threadID, postID, context) { let el; let inlined; const isBacklink = $.hasClass(quotelink, 'backlink'); let root = QuoteInline.findRoot(quotelink, isBacklink); root = $.x(`following-sibling::div[@data-full-i-d='${boardID}.${postID}'][1]`, root); const qroot = $.x('ancestor::*[contains(@class,"postContainer")][1]', root); const { parentNode } = root; $.rm(root); $.event('PostsRemoved', null, parentNode); if (!$('.inline', qroot)) { $.rmClass(qroot, 'hasInline'); } if (!(el = root.firstElementChild)) { return; } const post = g.posts.get(`${boardID}.${postID}`); post.rmClone(el.dataset.clone); if (Conf['Forward Hiding'] && isBacklink && (context.thread === g.threads.get(`${boardID}.${threadID}`)) && !--post.forwarded) { delete post.forwarded; $.rmClass(post.nodes.root, 'forwarded'); } while ((inlined = $('.inlined', el))) { ({ boardID, threadID, postID } = Get.postDataFromLink(inlined)); QuoteInline.rm(inlined, boardID, threadID, postID, context); $.rmClass(inlined, 'inlined'); } } }; function countBacklinks(classNames) { let count = 0; for (const className of classNames) { if (className.split(/\s+/).includes('backlink')) { count++; } } return count; } function tapAction(count) { return count <= 0 ? 'none' : 'popup'; } function chipLabel(count) { return count > 99 ? '99+' : String(count); } function canDrill(openFullIDs, fullID) { return !openFullIDs.includes(fullID); } function backAction(depth) { return depth > 1 ? 'pop' : 'close'; } function rootTitle(count) { return `${count} ${count === 1 ? 'reply' : 'replies'}`; } const MobileReplies = { popup: undefined, backdrop: undefined, list: undefined, title: undefined, stack: [], init() { if (g.VIEW !== 'index' && g.VIEW !== 'thread') { return; } if (!Conf['Quote Backlinks']) { return; } if (!resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { return; } MobileReplies.applyEnabled(Conf['Mobile Replies Popup']); $.sync('Mobile Replies Popup', MobileReplies.applyEnabled); $.on(d, 'click', MobileReplies.chipClick); $.on(d, 'MobileSheetOpened', MobileReplies.close); Callbacks.Post.push({ name: 'Mobile Replies', cb: this.node }); }, applyEnabled(enabled) { doc.classList.toggle('xt-replies-chip', !!enabled); if (!enabled) { MobileReplies.close(); } }, node() { if (this.isClone) { return; } MobileReplies.update(this); for (const quote of this.quotes) { const quoted = g.posts.get(quote); if (quoted) { MobileReplies.update(quoted); } } }, update(post) { const container = post.nodes.backlinkContainer; if (!container) { return; } const count = countBacklinks([...container.children].map(el => el.className)); let chip = post.nodes.repliesChip; if (!count) { if (chip) { chip.hidden = true; } return; } if (!chip) { chip = MobileReplies.buildChip(); post.nodes.repliesChip = chip; $.add(post.nodes.info, chip); } chip.hidden = false; $('.mobile-replies-count', chip).textContent = chipLabel(count); }, buildChip() { const chip = $.el('a', { href: 'javascript:;', className: 'mobile-replies-chip' }); const icon = $.el('span', { className: 'mobile-replies-icon' }); Icon.set(icon, 'comment'); const count = $.el('span', { className: 'mobile-replies-count' }); $.add(chip, [icon, count]); return chip; }, chipClick(e) { const target = e.target; const chip = target.closest?.('.mobile-replies-chip'); if (!chip) { return; } if (MobileReplies.popup?.contains(chip)) { return; } e.preventDefault(); const post = Get.postFromNode(chip); if (!post) { return; } MobileReplies.onChipTap(post.origin || post); }, onChipTap(post) { const container = post.nodes.backlinkContainer; if (!container) { return; } const links = [...container.getElementsByClassName('backlink')]; if (tapAction(links.length) === 'popup') { MobileReplies.open(post, links); } }, open(post, links) { MobileReplies.close(); const entries = links.map(link => { const { boardID, threadID, postID } = Get.postDataFromLink(link); return { boardID, threadID, postID, quoter: post }; }); MobileReplies.stack = [{ title: rootTitle(entries.length), fullID: post.fullID, entries }]; const popup = (MobileReplies.popup = $.el('div', { id: 'mobile-replies', className: 'dialog' })); const backdrop = (MobileReplies.backdrop = $.el('div', { id: 'mobile-replies-backdrop' })); const header = $.el('div', { className: 'mobile-replies-header' }); const backBtn = $.el('a', { href: 'javascript:;', className: 'mobile-replies-back' }); Icon.set(backBtn, 'caretLeft'); $.on(backBtn, 'click', (e) => { e.preventDefault(); MobileReplies.back(); }); const title = (MobileReplies.title = $.el('span', { className: 'mobile-replies-title' })); const closeBtn = $.el('a', { href: 'javascript:;', className: 'mobile-replies-close' }); Icon.set(closeBtn, 'xmark'); $.on(closeBtn, 'click', (e) => { e.preventDefault(); MobileReplies.close(); }); $.add(header, [backBtn, title, closeBtn]); const list = (MobileReplies.list = $.el('div', { className: 'mobile-replies-list' })); $.add(popup, [header, list]); popup.addEventListener('click', MobileReplies.popupClick, true); $.on(backdrop, 'click', MobileReplies.close); $.add(d.body, [backdrop, popup]); $.addClass(doc, 'xt-replies-open'); MobileReplies.render(); }, render() { const { list, title, stack } = MobileReplies; const view = stack[stack.length - 1]; if (!list || !title || !view) { return; } MobileReplies.cleanList(); title.textContent = view.title; for (const { boardID, threadID, postID, quoter } of view.entries) { const item = $.el('div', { className: 'mobile-replies-item' }); $.add(list, item); new Fetcher(boardID, threadID, postID, item, quoter); } list.scrollTop = 0; }, back() { switch (backAction(MobileReplies.stack.length)) { case 'pop': MobileReplies.stack.pop(); MobileReplies.render(); break; case 'close': MobileReplies.close(); break; } }, popupClick(e) { const { popup } = MobileReplies; if (!popup) { return; } const target = e.target; const chip = target.closest?.('.mobile-replies-chip'); if (chip && popup.contains(chip)) { e.stopPropagation(); e.preventDefault(); const clicked = Get.postFromNode(chip); const origin = clicked?.origin || clicked; if (!origin) { return; } if (!canDrill(MobileReplies.stack.map(view => view.fullID), origin.fullID)) { return; } const container = origin.nodes?.backlinkContainer; if (!container) { return; } const links = [...container.getElementsByClassName('backlink')]; if (!links.length) { return; } const entries = links.map(link => { const { boardID, threadID, postID } = Get.postDataFromLink(link); return { boardID, threadID, postID, quoter: origin }; }); MobileReplies.stack.push({ title: `${rootTitle(entries.length)} to >>${origin.ID}`, fullID: origin.fullID, entries }); MobileReplies.render(); return; } const link = target.closest?.('a.quotelink, a.backlink'); if (!link || !popup.contains(link)) { return; } e.stopPropagation(); e.preventDefault(); MobileReplies.toggleInline(link); }, toggleInline(link) { const { boardID, threadID, postID } = Get.postDataFromLink(link); if (!canDrill(MobileReplies.stack.map(view => view.fullID), `${boardID}.${postID}`)) { return; } const isBacklink = $.hasClass(link, 'backlink'); const root = QuoteInline.findRoot(link, isBacklink); if (!root) { return; } if ($.hasClass(link, 'inlined')) { const inline = $.x(`following-sibling::div[@data-full-i-d='${boardID}.${postID}'][1]`, root); if (inline) { MobileReplies.rmInline(inline); } } else { if ($.x(`ancestor::div[@data-full-i-d='${boardID}.${postID}']`, link)) { return; } const quoter = Get.postFromNode(link); if (!quoter) { return; } const inline = $.el('div', { className: 'inline' }); inline.dataset.fullID = `${boardID}.${postID}`; $.after(root, inline); new Fetcher(boardID, threadID, postID, inline, quoter); } link.classList.toggle('inlined'); }, rmInline(inline) { const parent = inline.parentNode ?? undefined; for (const div of [inline, ...inline.getElementsByClassName('inline')]) { const root = div.firstElementChild; if (!root?.dataset.clone) { continue; } Get.postFromRoot(root)?.origin?.rmClone(root.dataset.clone); } $.rm(inline); $.event('PostsRemoved', null, parent); }, cleanList() { const { list } = MobileReplies; if (!list) { return; } for (const root of [...list.querySelectorAll('[data-clone]')]) { Get.postFromRoot(root)?.origin?.rmClone(root.dataset.clone); } $.event('PostsRemoved', null, list); $.rmAll(list); }, close() { const { popup, backdrop } = MobileReplies; if (!popup) { return; } MobileReplies.cleanList(); $.rm(popup); if (backdrop) { $.rm(backdrop); } MobileReplies.popup = undefined; MobileReplies.backdrop = undefined; MobileReplies.list = undefined; MobileReplies.title = undefined; MobileReplies.stack = []; $.rmClass(doc, 'xt-replies-open'); } }; var ModContact = { init() { if ((g.SITE.software !== 'yotsuba') || (g.VIEW !== 'index' && g.VIEW !== 'thread')) { return; } return Callbacks.Post.push({ name: 'Mod Contact Links', cb: this.node }); }, node() { let moved; if (this.isClone || !$.hasOwn(ModContact.specific, this.info.capcode)) { return; } const links = $.el('span', { className: 'contact-links brackets-wrap' }); $.extend(links, ModContact.template(this.info.capcode)); $.after(this.nodes.capcode, links); if ((moved = this.info.comment.match(/This thread was moved to >>>\/(\w+)\//)) && $.hasOwn(ModContact.moveNote, moved[1])) { const moveNote = $.el('div', { className: 'move-note' }); $.extend(moveNote, ModContact.moveNote[moved[1]]); return $.add(this.nodes.post, moveNote); } }, template(capcode) { return { innerHTML: "feedback" + (ModContact.specific[capcode]()).innerHTML }; }, specific: { Mod() { return { innerHTML: " IRC" }; }, Manager() { return ModContact.specific.Mod(); }, Developer() { return { innerHTML: " github" }; }, Admin() { return { innerHTML: " twitter" }; } }, moveNote: { qa: { innerHTML: "Moving a thread to /qa/ does not imply mods will read it. If you wish to contact mods, use feedback (https://www.4chan.org/feedback) or IRC (https://www.4chan-x.net/4chan-irc.html)." } } }; const NormalizeURL = { init() { if (!Conf['Normalize URL']) { return; } let pathname = location.pathname.split(/\/+/); if (g.SITE.software === 'yotsuba') { switch (g.VIEW) { case 'thread': pathname[2] = 'thread'; pathname = pathname.slice(0, 4); break; case 'index': pathname = pathname.slice(0, 3); break; } } pathname = pathname.join('/'); if (location.pathname !== pathname) { return history.replaceState(history.state, '', `${location.protocol}//${location.host}${pathname}${location.hash}`); } } }; var PostJumper = { buttons: null, init() { if (!Conf['Unique ID and Capcode Navigation'] || (g.VIEW !== 'index' && g.VIEW !== 'thread')) { return; } this.buttons = this.makeButtons(); Icon.set(this.buttons.firstChild, 'arrowUpLong'); Icon.set(this.buttons.lastChild, 'arrowDownLong'); return Callbacks.Post.push({ name: 'Post Jumper', cb: this.node }); }, node() { if (this.isClone) { for (var buttons of $$('.postJumper', this.nodes.info)) { PostJumper.addListeners(buttons); } return; } if (this.nodes.uniqueIDRoot) { PostJumper.addButtons(this, 'uniqueID'); } if (this.nodes.capcode) { return PostJumper.addButtons(this, 'capcode'); } }, addButtons(post, type) { const value = post.info[type]; const buttons = PostJumper.buttons.cloneNode(true); $.extend(buttons.dataset, { type, value }); $.after(post.nodes[type + (type === 'capcode' ? '' : 'Root')], buttons); return PostJumper.addListeners(buttons); }, addListeners(buttons) { $.on(buttons.firstChild, 'click', PostJumper.buttonClick); return $.on(buttons.lastChild, 'click', PostJumper.buttonClick); }, buttonClick() { let toJumper; const dir = $.hasClass(this, 'prev') ? -1 : 1; if (toJumper = PostJumper.find(this.parentNode, dir)) { return PostJumper.scroll(this.parentNode, toJumper); } }, find(jumper, dir) { const { type, value } = jumper.dataset; const xpath = `span[contains(@class,\"postJumper\") and @data-value=\"${value}\" and @data-type=\"${type}\"]`; const axis = dir < 0 ? 'preceding' : 'following'; let jumper2 = jumper; while (jumper2 = $.x(`${axis}::${xpath}`, jumper2)) { if (jumper2.getBoundingClientRect().height) { return jumper2; } } if (jumper2 = $.x(`(//${xpath})[${dir < 0 ? 'last()' : '1'}]`)) { if (jumper2.getBoundingClientRect().height) { return jumper2; } } while ((jumper2 = $.x(`${axis}::${xpath}`, jumper2)) && (jumper2 !== jumper)) { if (jumper2.getBoundingClientRect().height) { return jumper2; } } return null; }, makeButtons() { const charPrev = '\u23EB'; const charNext = '\u23EC'; const classPrev = 'prev'; const classNext = 'next'; const span = $.el('span', { className: 'postJumper' }); $.extend(span, { innerHTML: "" + E(charPrev) + "" + E(charNext) + "" }); return span; }, scroll(fromJumper, toJumper) { const prevPos = fromJumper.getBoundingClientRect().top; const destPos = toJumper.getBoundingClientRect().top; return window.scrollBy(0, destPos - prevPos); } }; const PSA = { init() { let el; if ((g.SITE.software === 'yotsuba') && (g.BOARD.ID === 'qa')) { const announcement = { innerHTML: "Stay in touch with your /qa/ friends!" }; el = $.el('div', { className: 'fcx-announcement' }, announcement); $.onExists(doc, '.boardBanner', banner => $.after(banner, el)); } if ('samachan.org' in Conf['siteProperties'] && !Conf['PSAseen'].includes('samachan')) { el = $.el('span', { innerHTML: "Looking for a new home?
    Some former Samachan users are regrouping on SushiChan.

    (a message from 4chan X)" }); return Main.ready(function () { new Notice('info', el); Conf['PSAseen'].push('samachan'); return $.set('PSAseen', Conf['PSAseen']); }); } } }; var PSAHiding = { psa: null, text: null, hr: null, content: null, btn: null, init() { if (!Conf['Announcement Hiding'] || !g.SITE.selectors.psa) { return; } $.addClass(doc, 'hide-announcement'); $.onExists(doc, g.SITE.selectors.psa, this.setup); return $.ready(function () { if (!$(g.SITE.selectors.psa)) { return $.rmClass(doc, 'hide-announcement'); } }); }, setup(psa) { let btn, hr; PSAHiding.psa = psa; PSAHiding.text = psa.dataset.utc ?? psa.innerHTML; if (g.SITE.selectors.psaTop && (hr = $(g.SITE.selectors.psaTop)?.previousElementSibling) && (hr.nodeName === 'HR')) { PSAHiding.hr = hr; } PSAHiding.content = $.el('div'); const entry = { el: $.el('a', { textContent: 'Show announcement', className: 'show-announcement', href: 'javascript:;' }), order: 50, open() { return psa.hidden; } }; Header.menu.addEntry(entry); $.on(entry.el, 'click', PSAHiding.toggle); PSAHiding.btn = (btn = $.el('a', { title: 'Mark announcement as read and hide.', className: 'hide-announcement-button', href: 'javascript:;', textContent: '➖︎', })); Icon.set(btn, 'squareMinus'); $.on(btn, 'click', PSAHiding.toggle); if (psa.firstChild?.tagName === 'HR') { $.after(psa.firstChild, btn); } else { $.prepend(psa, btn); } PSAHiding.sync(Conf['hiddenPSAList']); $.rmClass(doc, 'hide-announcement'); return $.sync('hiddenPSAList', PSAHiding.sync); }, toggle() { const hide = $.hasClass(this, 'hide-announcement-button'); const set = function (hiddenPSAList) { if (hide) { return hiddenPSAList[g.SITE.ID] = PSAHiding.text; } else { return delete hiddenPSAList[g.SITE.ID]; } }; set(Conf['hiddenPSAList']); PSAHiding.sync(Conf['hiddenPSAList']); return $.get('hiddenPSAList', Conf['hiddenPSAList'], function ({ hiddenPSAList }) { set(hiddenPSAList); return $.set('hiddenPSAList', hiddenPSAList); }); }, sync(hiddenPSAList) { const { psa, content } = PSAHiding; psa.hidden = (hiddenPSAList[g.SITE.ID] === PSAHiding.text); if (psa.hidden) { $.add(content, [...psa.childNodes]); } else { $.add(psa, [...content.childNodes]); } if (PSAHiding.hr) PSAHiding.hr.hidden = psa.hidden; } }; var RemoveSpoilers = { init() { if (Conf['Reveal Spoilers']) { $.addClass(doc, 'reveal-spoilers'); } if (!Conf['Remove Spoilers']) { return; } Callbacks.Post.push({ name: 'Reveal Spoilers', cb: this.node }); if (g.VIEW === 'archive') { return $.ready(() => RemoveSpoilers.unspoiler($.id('arc-list'))); } }, node() { return RemoveSpoilers.unspoiler(this.nodes.comment); }, unspoiler(el) { const spoilers = $$(g.SITE.selectors.spoiler, el); for (var spoiler of spoilers) { var span = $.el('span', { className: 'removed-spoiler' }); $.replace(spoiler, span); $.add(span, [...spoiler.childNodes]); } } }; var ThreadLinks = { init() { if ((g.VIEW !== 'index') || !Conf['Open Threads in New Tab']) { return; } Callbacks.Post.push({ name: 'Thread Links', cb: this.node }); return Callbacks.CatalogThread.push({ name: 'Thread Links', cb: this.catalogNode }); }, node() { if (this.isReply || this.isClone) { return; } return ThreadLinks.process(this.nodes.reply); }, catalogNode() { return ThreadLinks.process(this.nodes.thumb.parentNode); }, process(link) { return link.target = '_blank'; } }; const Tinyboard = { init() { if (g.SITE.software !== 'tinyboard') { return; } if (g.VIEW === 'thread') { return Main.ready(() => $.global("initTinyBoard", { boardID: g.BOARD.ID, threadID: g.THREADID.toString() })); } } }; var MarkNewIPs = { ipCount: null, postCount: null, init() { if ((g.SITE.software !== 'yotsuba') || (g.VIEW !== 'thread') || !Conf['Mark New IPs']) { return; } return Callbacks.Thread.push({ name: 'Mark New IPs', cb: this.node }); }, node() { MarkNewIPs.ipCount = this.ipCount; MarkNewIPs.postCount = this.posts.keys.length; return $.on(d, 'ThreadUpdate', MarkNewIPs.onUpdate); }, onUpdate(e) { let fullID; const { ipCount, postCount, newPosts, deletedPosts } = e.detail; if (ipCount == null) { return; } switch (ipCount - MarkNewIPs.ipCount) { case (postCount - MarkNewIPs.postCount) + deletedPosts.length: var i = MarkNewIPs.ipCount; for (fullID of newPosts) { const post = g.posts.get(fullID); if (post) { MarkNewIPs.markNew(post, ++i); } } break; case -deletedPosts.length: for (fullID of newPosts) { const post = g.posts.get(fullID); if (post) { MarkNewIPs.markOld(post); } } break; } MarkNewIPs.ipCount = ipCount; return MarkNewIPs.postCount = postCount; }, markNew(post, ipCount) { const suffix = ((Math.floor(ipCount / 10)) % 10) === 1 ? 'th' : ['st', 'nd', 'rd'][(ipCount % 10) - 1] || 'th'; const counter = $.el('span', { className: 'ip-counter', textContent: `(${ipCount})` }); post.nodes.nameBlock.title = `This is the ${ipCount}${suffix} IP in the thread.`; $.add(post.nodes.nameBlock, [$.tn(' '), counter]); return $.addClass(post.nodes.root, 'new-ip'); }, markOld(post) { post.nodes.nameBlock.title = 'Not the first post from this IP.'; return $.addClass(post.nodes.root, 'old-ip'); } }; var ThreadStats = { postCount: 0, fileCount: 0, postIndex: 0, showPage: false, showPurgePos: false, thread: null, dialog: null, postCountEl: null, fileCountEl: null, ipCountEl: null, pageCountEl: null, timeout: 0, lastPageUpdate: null, init() { let sc; if ((g.VIEW !== 'thread') || !Conf['Thread Stats']) { return; } if (Conf['Page Count in Stats']) { this[g.SITE.isPrunedByAge?.(g.BOARD) ? 'showPurgePos' : 'showPage'] = true; } const statsHTML = { innerHTML: "? / ?" + ((Conf["IP Count in Stats"] && g.SITE.hasIPCount) ? " / ?" : "") + ((Conf["Page Count in Stats"]) ? " / ?" : "") }; let statsTitle = 'Posts / Files'; if (Conf['IP Count in Stats'] && g.SITE.hasIPCount) { statsTitle += ' / IPs'; } if (Conf['Page Count in Stats']) { if (this.showPurgePos) { statsTitle += ' / Purge Position'; } else { statsTitle += ' / Page'; if (Conf['Purge Position']) statsTitle += ' (Purge Position)'; } } if (Conf['Updater and Stats in Header']) { this.dialog = (sc = $.el('span', { id: 'thread-stats', title: statsTitle })); $.extend(sc, statsHTML); Header.addShortcut('stats', sc, 200); } else { this.dialog = (sc = UI.dialog('thread-stats', { innerHTML: "
    " + (statsHTML).innerHTML + "
    " })); $.addClass(doc, 'float'); $.ready(() => $.add(d.body, sc)); } this.postCountEl = $('#post-count', sc); this.fileCountEl = $('#file-count', sc); this.ipCountEl = $('#ip-count', sc); this.pageCountEl = $('#page-count', sc); if (this.pageCountEl) { $.on(this.pageCountEl, 'click', ThreadStats.fetchPage); } return Callbacks.Thread.push({ name: 'Thread Stats', cb: this.node }); }, node() { ThreadStats.thread = this; ThreadStats.count(); ThreadStats.update(); ThreadStats.fetchPage(); $.on(d, 'PostsInserted', () => $.queueTask(ThreadStats.onPostsInserted)); return $.on(d, 'ThreadUpdate', ThreadStats.onUpdate); }, count() { const { posts } = ThreadStats.thread; const n = posts.keys.length; for (let i = ThreadStats.postIndex, end = n; i < end; i++) { var post = posts.get(posts.keys[i]); if (!post.isFetchedQuote) { ThreadStats.postCount++; ThreadStats.fileCount += post.files.length; } } ThreadStats.postIndex = n; }, onUpdate(e) { if (e.detail[404]) { return; } const { postCount, fileCount } = e.detail; $.extend(ThreadStats, { postCount, fileCount }); ThreadStats.postIndex = ThreadStats.thread.posts.keys.length; ThreadStats.update(); if (ThreadStats.showPage && (ThreadStats.pageCountEl.textContent !== '1')) { return ThreadStats.fetchPage(); } }, onPostsInserted() { if (ThreadStats.thread.posts.keys.length <= ThreadStats.postIndex) { return; } ThreadStats.count(); ThreadStats.update(); if (ThreadStats.showPage && (ThreadStats.pageCountEl.textContent !== '1')) { return ThreadStats.fetchPage(); } }, update() { const { thread, postCountEl, fileCountEl, ipCountEl } = ThreadStats; postCountEl.textContent = ThreadStats.postCount; fileCountEl.textContent = ThreadStats.fileCount; if (ipCountEl) { if (thread.ipCount) { ipCountEl.textContent = thread.ipCount; } else if (g.BOARD?.config.user_ids) { const IDs = new Set(); g.posts.forEach(post => { IDs.add(post.info.uniqueID); }); ipCountEl.textContent = IDs.size; } else { ipCountEl.textContent = '?'; } } postCountEl.classList.toggle('warning', (thread.postLimit && !thread.isSticky)); fileCountEl.classList.toggle('warning', (thread.fileLimit && !thread.isSticky)); }, fetchPage() { if (!ThreadStats.pageCountEl) { return; } clearTimeout(ThreadStats.timeout); if (ThreadStats.thread.isDead) { ThreadStats.pageCountEl.textContent = 'Dead'; $.addClass(ThreadStats.pageCountEl, 'warning'); return; } ThreadStats.timeout = window.setTimeout(ThreadStats.fetchPage, Conf['Purge Position'] && ThreadStats.pageCountEl.classList.contains('warning') ? (5 * SECOND) : (2 * MINUTE)); $.whenModified(g.SITE.urls.threadsListJSON(ThreadStats.thread), 'ThreadStats', ThreadStats.onThreadsLoad); }, onThreadsLoad() { if (this.status === 200) { let page, thread; if (ThreadStats.showPurgePos) { let purgePos = 1; for (page of this.response) { for (thread of page.threads) { if (thread.no < ThreadStats.thread.ID) { purgePos++; } } } ThreadStats.pageCountEl.textContent = purgePos; ThreadStats.pageCountEl.classList.toggle('warning', (purgePos === 1)); } else { let nThreads; let i = (nThreads = 0); for (page of this.response) { nThreads += page.threads.length; } for (let pageNum = 0; pageNum < this.response.length; pageNum++) { page = this.response[pageNum]; for (thread of page.threads) { if (thread.no === ThreadStats.thread.ID) { ThreadStats.pageCountEl.textContent = pageNum + 1; const hasWarning = (i >= (nThreads - this.response[0].threads.length)); ThreadStats.pageCountEl.classList.toggle('warning', hasWarning); if (hasWarning && Conf['Purge Position']) { ThreadStats.pageCountEl.textContent += ` (${nThreads - i - 1})`; } ThreadStats.lastPageUpdate = new Date(thread.last_modified * SECOND); ThreadStats.retry(); return; } i++; } } } } else if (this.status === 304) { ThreadStats.retry(); } }, retry() { if (!ThreadStats.showPage || (ThreadStats.pageCountEl.textContent === '1') || !!g.SITE.threadModTimeIgnoresSage || (ThreadStats.thread.posts.get(ThreadStats.thread.lastPost).info.date <= ThreadStats.lastPageUpdate)) { return; } clearTimeout(ThreadStats.timeout); ThreadStats.timeout = window.setTimeout(ThreadStats.fetchPage, 5 * SECOND); } }; const PassLink = { init() { if ((g.SITE.software !== 'yotsuba') || !Conf['Pass Link']) { return; } return Main.ready(PassLink.ready); }, open() { window.open(`//sys.${location.hostname.split('.')[1]}.org/auth`, String(Date.now()), 'width=500,height=280,toolbar=0'); }, ready() { if (resolveMobileLayout(Conf['Mobile Layout'], detectMobileDevice())) { const entry = $.el('a', { href: 'javascript:;', textContent: '4chan Pass' }); $.on(entry, 'click', PassLink.open); Header.menu.addEntry({ el: entry, order: 300, open() { return d.cookie.indexOf('pass_enabled=1') < 0; } }); } let styleSelector; if (!(styleSelector = $.id('styleSelector'))) { return; } const passLink = $.el('span', { className: 'brackets-wrap pass-link-container' }); $.extend(passLink, { innerHTML: "4chan Pass" }); $.on(passLink.firstElementChild, 'click', PassLink.open); return $.before(styleSelector.previousSibling, [passLink, $.tn('  ')]); } }; var QuoteBacklink = { containers: dict(), bottomBacklinks: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Quote Backlinks']) { return; } if (this.bottomBacklinks = Conf['Bottom Backlinks']) { $.addClass(doc, 'bottom-backlinks'); } Callbacks.Post.push({ name: 'Quote Backlinking Part 1', cb: this.firstNode }); return Callbacks.Post.push({ name: 'Quote Backlinking Part 2', cb: this.secondNode }); }, firstNode() { if (this.isClone || !this.quotes.length || this.isRebuilt) { return; } const markYours = Conf['Mark Quotes of You'] && QuoteYou.isYou(this); const a = $.el('a', { href: g.SITE.Build.postURL(this.board.ID, this.thread.ID, this.ID), className: this.isHidden ? 'filtered backlink' : 'backlink', textContent: Conf['backlink'].replace(/%(?:id|%)/g, (x) => { const map = { '%id': this.ID, '%%': '%' }; return map[x]; }) }); if (markYours) { $.add(a, QuoteYou.mark.cloneNode(true)); } for (var quote of this.quotes) { var post; var containers = [QuoteBacklink.getContainer(quote)]; if ((post = g.posts.get(quote)) && post.nodes.backlinkContainer) { for (var clone of post.clones) { containers.push(clone.nodes.backlinkContainer); } } for (var container of containers) { var link = a.cloneNode(true); var nodes = container.firstChild ? [$.tn(' '), link] : [link]; if (Conf['Quote Previewing']) { $.on(link, 'mouseover', QuotePreview.mouseover); } if (Conf['Quote Inlining']) { $.on(link, 'click', QuoteInline.toggle); if (Conf['Quote Hash Navigation']) { var hash = QuoteInline.qiQuote(link, $.hasClass(link, 'filtered')); nodes.push(hash); } } $.add(container, nodes); } } }, secondNode() { if (this.isClone && (this.origin.isReply || Conf['OP Backlinks'])) { this.nodes.backlinkContainer = $('.container', this.nodes.post); return; } if (!this.isReply && !Conf['OP Backlinks']) { return; } const container = QuoteBacklink.getContainer(this.fullID); this.nodes.backlinkContainer = container; if (QuoteBacklink.bottomBacklinks) { return $.add(this.nodes.post, container); } else { return $.add(this.nodes.info, container); } }, getContainer(id) { return this.containers[id] || (this.containers[id] = $.el('span', { className: 'container' })); } }; var QuoteCT = { mark: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Mark Cross-thread Quotes']) { return; } if (Conf['Comment Expansion']) { ExpandComment.callbacks.push(this.node); } this.mark = $.el('span', { textContent: '\u00A0(Cross-thread)', className: 'qmark-ct' }); return Callbacks.Post.push({ name: 'Mark Cross-thread Quotes', cb: this.node }); }, node() { if (this.isClone && (this.thread === this.context.thread)) { return; } const { board, thread } = this.context; for (var quotelink of this.nodes.quotelinks) { var { boardID, threadID } = Get.postDataFromLink(quotelink); if (!threadID) { continue; } if (this.isClone) { $.rm($('.qmark-ct', quotelink)); } if ((boardID === board.ID) && (threadID !== thread.ID)) { $.add(quotelink, QuoteCT.mark.cloneNode(true)); } } } }; var QuoteOP = { mark: null, init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Mark OP Quotes']) { return; } if (Conf['Comment Expansion']) { ExpandComment.callbacks.push(this.node); } this.mark = $.el('span', { textContent: '\u00A0(OP)', className: 'qmark-op' }); return Callbacks.Post.push({ name: 'Mark OP Quotes', cb: this.node }); }, node() { let i, quotelink, quotes; if (this.isClone && (this.thread === this.context.thread)) { return; } if (!(quotes = this.quotes).length) { return; } const { quotelinks } = this.nodes; if (this.isClone && quotes.includes(this.thread.fullID)) { i = 0; while ((quotelink = quotelinks[i++])) { $.rm($('.qmark-op', quotelink)); } } const { fullID } = this.context.thread; if (!quotes.includes(fullID)) { return; } i = 0; while ((quotelink = quotelinks[i++])) { var { boardID, postID } = Get.postDataFromLink(quotelink); if (`${boardID}.${postID}` === fullID) { $.add(quotelink, QuoteOP.mark.cloneNode(true)); } } } }; const QuoteStrikeThrough = { init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || (!Conf['Reply Hiding Buttons'] && (!Conf['Menu'] || !Conf['Reply Hiding Link']) && !Conf['Filter'])) { return; } return Callbacks.Post.push({ name: 'Strike-through Quotes', cb: this.node }); }, node() { if (this.isClone) { return; } for (var quotelink of this.nodes.quotelinks) { var { boardID, postID } = Get.postDataFromLink(quotelink); if (g.posts.get(`${boardID}.${postID}`)?.isHidden) { $.addClass(quotelink, 'filtered'); } } } }; var Quotify = { init() { if ((g.VIEW !== 'index' && g.VIEW !== 'thread') || !Conf['Resurrect Quotes']) { return; } $.addClass(doc, 'resurrect-quotes'); if (Conf['Comment Expansion']) { ExpandComment.callbacks.push(this.node); } return Callbacks.Post.push({ name: 'Resurrect Quotes', cb: this.node }); }, node() { if (this.isClone) { this.nodes.archivelinks = $$('a.linkify.quotelink', this.nodes.comment); return; } for (var link of $$('a.linkify', this.nodes.comment)) { Quotify.parseArchivelink.call(this, link); } for (var deadlink of $$('.deadlink', this.nodes.comment)) { Quotify.parseDeadlink.call(this, deadlink); } }, parseArchivelink(link) { let m; if (!(m = link.pathname.match(/^\/([^/]+)\/thread\/S?(\d+)\/?$/))) { return; } if (['boards.4chan.org', 'boards.4channel.org'].includes(link.hostname)) { return; } const boardID = m[1]; const threadID = m[2]; const postID = link.hash.match(/^#[pq]?(\d+)$|$/)[1] || threadID; if (Redirect.to('post', { boardID, postID })) { $.addClass(link, 'quotelink'); $.extend(link.dataset, { boardID, threadID, postID }); return this.nodes.archivelinks.push(link); } }, parseDeadlink(deadlink) { let a, m, post, postID; if ($.hasClass(deadlink.parentNode, 'prettyprint')) { Quotify.fixDeadlink(deadlink); return; } const quote = deadlink.textContent; if (!(postID = quote.match(/\d+$/)?.[0])) { return; } if (postID[0] === '0') { Quotify.fixDeadlink(deadlink); return; } const boardID = (m = quote.match(/^>>>\/([a-z\d]+)/)) ? m[1] : this.board.ID; const quoteID = `${boardID}.${postID}`; if (post = g.posts.get(quoteID)) { if (!post.isDead) { a = $.el('a', { href: g.SITE.Build.postURL(boardID, post.thread.ID, postID), className: 'quotelink', textContent: quote }); } else { a = $.el('a', { href: g.SITE.Build.postURL(boardID, post.thread.ID, postID), className: 'quotelink deadlink', textContent: quote }); $.add(a, Post.deadMark.cloneNode(true)); $.extend(a.dataset, { boardID, threadID: post.thread.ID, postID }); } } else { const redirect = Redirect.to('thread', { boardID, threadID: 0, postID }); const fetchable = Redirect.to('post', { boardID, postID }); if (redirect || fetchable) { a = $.el('a', { href: redirect || 'javascript:;', className: 'deadlink', textContent: quote }); $.add(a, Post.deadMark.cloneNode(true)); if (fetchable) { $.addClass(a, 'quotelink'); $.extend(a.dataset, { boardID, postID }); } } } if (!this.quotes.includes(quoteID)) { this.quotes.push(quoteID); } if (!a) { $.add(deadlink, Post.deadMark.cloneNode(true)); return; } $.replace(deadlink, a); if ($.hasClass(a, 'quotelink')) { return this.nodes.quotelinks.push(a); } }, fixDeadlink(deadlink) { let el; if (!(el = deadlink.previousSibling) || (el.nodeName === 'BR')) { const green = $.el('span', { className: 'quote' }); $.before(deadlink, green); $.add(green, deadlink); } return $.replace(deadlink, [...deadlink.childNodes]); } }; const GhostPosts = { thread: null, fetched: false, init() { if (g.VIEW !== 'thread') return; if (!Conf['Fetch Ghost Posts']) return; if (!Conf['Resurrect Quotes']) return; Callbacks.Thread.push({ name: 'Ghost Posts', cb: GhostPosts.node, }); }, node() { GhostPosts.thread = this; GhostPosts.fetched = false; $.one(d, '4chanXInitFinished', GhostPosts.start); $.on(d, 'ThreadUpdate', GhostPosts.onThreadUpdate); }, onThreadUpdate(e) { if (e.detail[404]) return; if (!e.detail.deletedPosts?.length) return; GhostPosts.fetched = false; GhostPosts.start(); }, start() { if (GhostPosts.fetched) return; if (!GhostPosts.thread) return; GhostPosts.fetched = true; const { boardID, ID: threadID } = GhostPosts.thread; const url = Redirect.to('threadJSON', { boardID: String(boardID), threadID }); if (!url) return; if (!Redirect.securityCheck(url)) return; CrossOrigin.cache(url, function () { GhostPosts.handle(this); }); }, handle(req) { const { status, response } = req; if (![200, 304].includes(status) || !response) return; if (!GhostPosts.thread) return; const threadID = GhostPosts.thread.ID; const threadObj = response[String(threadID)] ?? response[threadID]; if (!threadObj?.posts) return; for (const postIDStr of Object.keys(threadObj.posts)) { const postID = +postIDStr; if (!postID) continue; const raw = threadObj.posts[postIDStr]; try { RestoreDeletedFromArchive.insert(raw); } catch (err) { c.error?.('GhostPosts insert failed for', postID, err); } } }, }; var Main = { init() { if (location.search.includes("__cf")) return; if (Main.isHomePage()) { Main.initHomePageStyleBridge(); return; } Main.maybeDisableNativeExtensionEarly(); try { let w = window; if (`${meta.name} antidup` in w) { return; } w[`${meta.name} antidup`] = true; } catch (error) { } try { if (window.frameElement && ['', 'about:blank'].includes(window.frameElement.src)) { return; } } catch (error1) { } if (doc && $.hasClass(doc, 'fourchan-x')) { return; } $.asap(docSet, function () { $.addClass(doc, 'fourchan-next', 'fourchan-xt', 'fourchan-x', 'seaweedchan'); if ($.engine) $.addClass(doc, `ua-${$.engine}`); BoardConfig.ready(() => { if (g.BOARD?.config.ws_board != null) $.addClass(doc, g.BOARD.config.ws_board ? 'ws' : 'nws'); }); }); try { $.global('exposeVersion', { version: g.VERSION, buildDate: g.VERSION_DATE.getTime().toString() }); } catch (e) { console.error(e); } $.on(d, '4chanXInitFinished', function () { if (Main.expectInitFinished) { return delete Main.expectInitFinished; } else { new Notice('error', `Error: Multiple copies of ${meta.name} or 4chan X are enabled.`); return $.addClass(doc, 'tainted'); } }); var mountedCB = function () { d.removeEventListener('mounted', mountedCB, true); Main.isMounted = true; return Main.mountedCBs.map((cb) => (() => { try { return cb(); } catch (error2) { } })()); }; d.addEventListener('mounted', mountedCB, true); var flatten = function (parent, obj) { if (obj instanceof Array) { Conf[parent] = dict.clone(obj[0]); } else if (typeof obj === 'object') { for (var key in obj) { flatten(key, obj[key]); } } else { Conf[parent] = obj; } }; if (location.hostname === 'boards.4chan.org') { $.asap(docSet, () => $.onExists(doc, 'iframe[srcdoc]', $.rm)); } flatten(null, Config); const legacyReplaceThumbnailKeys = ['Replace GIF', 'Replace JPG', 'Replace PNG', 'Replace WEBM']; const legacyQRRemoveFileFirstKeys = ['QR Remove Button Clears File First', 'QR Thumbnail Remove File First']; for (var db of DataBoard.keys) { Conf[db] = dict(); } Conf['customTitles'] = dict.clone({ '4chan.org': { boards: { 'qa': { 'boardTitle': { orig: '/qa/ - Question & Answer', title: '/qa/ - 2D/Random' } } } } }); Conf['boardConfig'] = { boards: dict() }; Conf['archives'] = Redirect.archives; Conf['selectedArchives'] = dict(); Conf['cooldowns'] = dict(); Conf['Index Sort'] = dict(); for (let i = 0; i < 2; i++) { Conf[`Last Long Reply Thresholds ${i}`] = dict(); } Conf['siteProperties'] = dict(); Conf['Comment Preview Float Position'] = dict(); Conf['Except Archives from Encryption'] = false; Conf['JSON Navigation'] = true; Conf['Oekaki Links'] = true; Conf['Show Name and Subject'] = false; Conf['QR Shortcut'] = true; Conf['Bottom QR Link'] = true; Conf['Toggleable Thread Watcher'] = true; Conf['siteSoftware'] = ''; Conf['Use Faster Image Host'] = 'true'; Conf['Captcha Fixes'] = true; Conf['captchaServiceDomain'] = ''; Conf['captchaServiceKey'] = dict(); if (/\.4chan(?:nel)?\.org$/.test(location.hostname) && !SW.yotsuba.regexp.pass.test(location.href) && !SW.yotsuba.regexp.captcha.test(location.href) && !$$('script:not([src])', d).filter(s => /this\[/.test(s.textContent)).length) { ($.getSync || $.get)({ 'jsWhitelist': Conf['jsWhitelist'] }, ({ jsWhitelist }) => { const sources = jsWhitelist.replace(/^#.*$/mg, '').split(/[\s;]+/).filter(Boolean); const mathBoard = $$('script:not([src])', d).some(s => /\bmath_tags\s*=\s*true\b/.test(s.textContent || '')); if (mathBoard) { sources.push('https://cdnjs.cloudflare.com', 'https://cdn.mathjax.org'); } const extras = [`'nonce-${$.getCSPNonce()}'`]; if (mathBoard) extras.push(`'unsafe-eval'`); const parsedList = [...new Set(sources)].join(' '); if (parsedList) $.addCSP(`script-src ${parsedList} ${extras.join(' ')}`); }); } const items = dict(); for (const key in Conf) items[key] = undefined; for (const key of legacyReplaceThumbnailKeys) items[key] = undefined; for (const key of legacyQRRemoveFileFirstKeys) items[key] = undefined; items['previousversion'] = undefined; ($.getSync || $.get)(items, function (items) { $.asap(docSet, function () { if ($.cantSet) ; else if ((items.previousversion == null)) { Main.isFirstRun = true; items['Stacked TCaptcha'] = true; items['Scrollbar Markers'] = false; Main.ready(function () { $.set('previousversion', g.VERSION); $.set('Stacked TCaptcha', true); $.set('Scrollbar Markers', false); return Settings.open(); }); } else if (items.previousversion !== g.VERSION) { Main.upgrade(items); } for (const key in Conf) { Conf[key] = items[key] ?? Conf[key]; } Icon.setIconSet(Conf['Icon Set']); if (!Conf['Replace Thumbnails'] && legacyReplaceThumbnailKeys.some((key) => items[key] === true)) { Conf['Replace Thumbnails'] = true; $.set('Replace Thumbnails', true); } const legacyQRRemoveFileFirst = legacyQRRemoveFileFirstKeys .map(key => items[key]) .find(value => value !== undefined); if (legacyQRRemoveFileFirst !== undefined && items['Dump List Remove File First'] === undefined) { Conf['Dump List Remove File First'] = legacyQRRemoveFileFirst; $.set('Dump List Remove File First', legacyQRRemoveFileFirst); } Site.init(Main.initFeatures); }); }); }, maybeDisableNativeExtensionEarly() { if (!/\.4chan(?:nel)?\.org$/.test(location.hostname)) { return; } const apply = function (disableNativeExtension) { if (!disableNativeExtension) { return; } if ($.hasStorage) { $.global('disableNativeExtension'); } else { $.global('disableNativeExtensionNoStorage'); } }; if ($.getSync) { try { const raw = $.getValue?.(g.NAMESPACE + 'Disable Native Extension'); if (raw != null) { apply(dict.json(raw)); return; } } catch (err) { } } ($.getSync || $.get)({ 'Disable Native Extension': true }, items => { apply(items['Disable Native Extension']); }); }, isHomePage() { if (!['www.4chan.org', 'www.4channel.org', '4chan.org', '4channel.org'].includes(location.hostname)) { return false; } return /^\/(?:index\.php)?\/?$/.test(location.pathname); }, normalizeSiteStyle(style) { if (!style) return ''; const normalized = String(style) .trim() .toLowerCase() .replace(/\bnew\b/g, '') .trim() .replace(/[_\s]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); return normalized; }, initHomePageStyleBridge() { const defaults = { siteStyleHome: false, sfwNsfwMode: 'auto', 'siteStyle SFW': '', 'siteStyle NSFW': '', customCSSHome: false, 'Custom CSS': true, 'usercss SFW': '', 'usercss NSFW': '', stylingSectionSiteStyle: true, stylingSectionCustomCSS: true, }; ($.getSync || $.get)(defaults, (items) => { const variant = items.sfwNsfwMode === 'nsfw' ? 'NSFW' : 'SFW'; let siteStyle = items[`siteStyle ${variant}`] || ''; const usercss = items[`usercss ${variant}`] || ''; if (typeof siteStyle === 'string' && siteStyle.startsWith('custom:')) { siteStyle = ''; } const normalizedStyle = Main.normalizeSiteStyle(siteStyle); const siteStyleSectionOn = items.stylingSectionSiteStyle !== false; const customCSSSectionOn = items.stylingSectionCustomCSS !== false; if (items.siteStyleHome && siteStyleSectionOn && normalizedStyle) { Main.setSiteStyleHomeCookie(siteStyle); $.addClass(doc, normalizedStyle); Main.applyHomePageSiteStyle(siteStyle); } if (items.customCSSHome && customCSSSectionOn && items['Custom CSS'] && usercss) { Main.installHomePageCustomCSS(usercss); } }); }, applyHomePageSiteStyle(preferred) { const want = Main.normalizeSiteStyle(preferred); if (!want) return; $.onExists(doc, 'head', () => { $.rm($.id('fourchanx-homepage-theme-css')); $.addStyle(www, 'fourchanx-homepage-theme-css'); Main.ensureHomePageCustomCSSLast(); }); }, ensureHomePageCustomCSSLast() { const style = $.id('custom-css-home'); if (style && d.head && d.head.lastElementChild !== style) { $.add(d.head, style); } }, installHomePageCustomCSS(usercss) { let style = null; const ensure = () => { if (!d.head) return; if (!style || !style.isConnected) { style = $.el('style', { id: 'custom-css-home', textContent: usercss }); $.add(d.head, style); return; } if (style.textContent !== usercss) style.textContent = usercss; if (d.head.lastElementChild !== style) $.add(d.head, style); }; $.onExists(doc, 'head', () => { ensure(); new MutationObserver(ensure).observe(d.head, { childList: true }); d.head.addEventListener('load', ensure, true); }); $.on(window, 'pageshow', ensure); $.on(window, 'load', ensure); }, upgrade(items) { const { previousversion } = items; const changes = Settings.upgrade(items, previousversion); items.previousversion = (changes.previousversion = g.VERSION); return $.set(changes, function () { if (items['Show Updated Notifications'] ?? true) { const el = $.el('span', { innerHTML: `${meta.name} has been updated to version ${g.VERSION}.` }); return new Notice('info', el, 15); } }); }, parseURL(site = g.SITE, url = location) { const r = {}; if (!site) { return r; } r.siteID = site.ID; if (site.isBoardlessPage?.(url)) { return r; } const pathname = url.pathname.split(/\/+/); r.boardID = pathname[1]; if (site.isFileURL(url)) { r.VIEW = 'file'; } else if (site.isAuxiliaryPage?.(url)) ; else if (['thread', 'res'].includes(pathname[2])) { r.VIEW = 'thread'; r.threadID = (r.THREADID = +pathname[3].replace(/\.\w+$/, '')); } else if ((pathname[2] === 'archive') && (pathname[3] === 'res')) { r.VIEW = 'thread'; r.threadID = (r.THREADID = +pathname[4].replace(/\.\w+$/, '')); r.threadArchived = true; } else if (/^(?:catalog|archive)(?:\.\w+)?$/.test(pathname[2])) { r.VIEW = pathname[2].replace(/\.\w+$/, ''); } else if (/^(?:index|\d*)(?:\.\w+)?$/.test(pathname[2])) { r.VIEW = 'index'; } return r; }, initFeatures() { $.global('initMain'); Main.jsEnabled = $.hasClass(doc, 'js-enabled'); $.extend(g, Main.parseURL()); if (g.boardID) { g.BOARD = new Board(g.boardID); } if (!g.VIEW) { g.SITE.initAuxiliary?.(); return; } if (g.VIEW === 'file') { $.asap((() => d.readyState !== 'loading'), function () { let video; if ((g.SITE.software === 'yotsuba') && Conf['404 Redirect'] && g.SITE.is404?.()) { const pathname = location.pathname.split(/\/+/); return Redirect.navigate('file', { boardID: g.BOARD.ID, filename: pathname[pathname.length - 1] }); } else if (video = $('video')) { if (Conf['Volume in New Tab']) { Volume.setup(video); } if (Conf['Loop in New Tab']) { video.loop = true; video.controls = true; video.play(); } } }); return; } g.threads = new SimpleDict(); g.posts = new SimpleDict(); $.onExists(doc, 'body', Main.initStyle); for (var [name, feature] of Main.features) { if (g.SITE.disabledFeatures && g.SITE.disabledFeatures.includes(name)) { continue; } try { feature.init(); } catch (err) { Main.handleErrors({ message: `\"${name}\" initialization crashed.`, error: err }); } } return $.ready(Main.initReady); }, initStyle() { if (!Main.isThisPageLegit()) { return; } const homeSiteStyle = Settings.styleConf('siteStyle'); Settings.onExternalStylerReady((present) => { (present ? $.addClass : $.rmClass)(doc, 'xt-external-style'); Settings.initStylingSectionDefaults(present); }); if (Conf['siteStyleHome'] && Settings.stylingSectionEnabled('siteStyle') && homeSiteStyle) { Main.setSiteStyleHomeCookie(homeSiteStyle); } const mobileLink = $('link[href*=mobile]', d.head); if (mobileLink) mobileLink.disabled = true; doc.dataset.host = location.host; $.addClass(doc, `sw-${g.SITE.software}`); $.addClass(doc, g.VIEW === 'thread' ? 'thread-view' : g.VIEW); applyMobileLayout(Conf['Mobile Layout'], doc); $.onExists(doc, '.ad-cnt, .adg-rects > .desktop', ad => $.onExists(ad, 'img, iframe', () => $.addClass(doc, 'ads-loaded'))); if (Conf['Autohiding Scrollbar']) { $.addClass(doc, 'autohiding-scrollbar'); } $.ready(function () { const autohiding = ScrollMarkers.measureScrollbarWidth() === 0; if (autohiding !== Conf['Autohiding Scrollbar']) { Conf['Autohiding Scrollbar'] = autohiding; $.set('Autohiding Scrollbar', autohiding); (autohiding ? $.addClass : $.rmClass)(doc, 'autohiding-scrollbar'); } }); $.addStyle(CSS.sub(CSS.boards), 'fourchanx-css'); Main.bgColorStyle = $.el('style', { id: 'fourchanx-bgcolor-css' }); return Main.setClass(); }, setClass() { let mainStyleSheet, style, styleSheets; const knownStyles = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'photon', 'tomorrow', 'spooky']; const customPrefix = 'custom:'; const isCustomSiteStyle = (v) => typeof v === 'string' && v.startsWith(customPrefix); const findCustomTheme = (value) => { if (!isCustomSiteStyle(value)) return null; const name = value.slice(customPrefix.length); const list = Array.isArray(Conf['customSiteThemes']) ? Conf['customSiteThemes'] : []; return list.find(t => t && t.name === name) || null; }; const applyCustomTheme = (theme) => { if (!theme) { if (Main.customSiteThemeStyle) { $.rm(Main.customSiteThemeStyle); delete Main.customSiteThemeStyle; } if (mainStyleSheet && mainStyleSheet.disabled) { mainStyleSheet.disabled = false; } $.rmClass(doc, 'xt-custom-site-theme'); return false; } for (const cls of knownStyles) $.rmClass(doc, cls); style = null; if (mainStyleSheet) mainStyleSheet.disabled = true; if (!Main.customSiteThemeStyle) { Main.customSiteThemeStyle = $.el('style', { id: 'xt-custom-site-theme-css' }); } Main.customSiteThemeStyle.textContent = String(theme.css || ''); const anchor = $.id('fourchanx-css'); if (anchor && anchor.parentNode) { anchor.parentNode.insertBefore(Main.customSiteThemeStyle, anchor); } else if (d.head) { $.add(d.head, Main.customSiteThemeStyle); } $.addClass(doc, 'xt-custom-site-theme'); Settings.applyStylingVarsDeferred(); return true; }; let preferredStyleApplied = false; const applyPreferredStyle = function () { const activeSiteStyle = Settings.styleConf('siteStyle'); if (!Settings.stylingSectionEnabled('siteStyle')) { return; } if (preferredStyleApplied || g.SITE.software !== 'yotsuba' || !activeSiteStyle) { return; } const preferred = activeSiteStyle; if (isCustomSiteStyle(preferred)) { const theme = findCustomTheme(preferred); if (theme) { applyCustomTheme(theme); preferredStyleApplied = true; } return; } const styleSelector = $.id('styleSelector'); if (styleSelector?.options?.length) { const hasPreferred = Array.from(styleSelector.options).some((option) => option.value === preferred); if (hasPreferred) { if (styleSelector.value !== preferred) { styleSelector.value = preferred; styleSelector.dispatchEvent(new Event('change', { bubbles: true })); } preferredStyleApplied = true; return; } } if (!styleSheets || !mainStyleSheet) { return; } for (var styleSheet of styleSheets) { if (styleSheet.title?.trim() === preferred) { if (mainStyleSheet.href !== styleSheet.href) { mainStyleSheet.href = styleSheet.href; } preferredStyleApplied = true; break; } } }; if ((g.SITE.software === 'yotsuba') && (g.VIEW === 'catalog')) { if (mainStyleSheet = $.id('base-css')) { style = mainStyleSheet.href.match(/catalog_(\w+)/)?.[1].replace('_new', '').replace(/_+/g, '-'); if (knownStyles.includes(style)) { for (const cls of knownStyles) $.rmClass(doc, cls); $.addClass(doc, style); Settings.applyStylingVars(); return; } } } style = (mainStyleSheet = (styleSheets = null)); const setStyle = function () { let activeStyleTitle = null; let customThemeApplied = false; const currentSiteStyle = Settings.styleConf('siteStyle'); if (g.SITE.software === 'yotsuba' && isCustomSiteStyle(currentSiteStyle)) { const theme = findCustomTheme(currentSiteStyle); if (theme) { $.rmClass(doc, style); if (applyCustomTheme(theme)) { preferredStyleApplied = true; customThemeApplied = true; } } } else { applyCustomTheme(null); } if (g.SITE.software === 'yotsuba' && !customThemeApplied) { for (const cls of knownStyles) $.rmClass(doc, cls); style = null; for (var styleSheet of styleSheets) { if (styleSheet.href === mainStyleSheet?.href) { activeStyleTitle = styleSheet.title?.trim() || null; style = styleSheet.title.toLowerCase().replace('new', '').trim().replace(/\s+/g, '-'); if (style === '_special') { style = styleSheet.href.match(/[a-z]*(?=[^/]*$)/)[0]; } if (!knownStyles.includes(style)) { style = null; } break; } } if (activeStyleTitle && !isCustomSiteStyle(currentSiteStyle) && !currentSiteStyle) { const siteStyleKey = Settings.variantKey('siteStyle'); Conf[siteStyleKey] = activeStyleTitle; $.set(siteStyleKey, activeStyleTitle); if (Conf['siteStyleHome'] && Settings.stylingSectionEnabled('siteStyle')) { Main.setSiteStyleHomeCookie(activeStyleTitle); } } if (style) { $.addClass(doc, style); $.rm(Main.bgColorStyle); Settings.applyStylingVarsDeferred(); return; } } const div = g.SITE.bgColoredEl(); div.style.position = 'absolute'; div.style.visibility = 'hidden'; $.add(d.body, div); const divStyle = window.getComputedStyle(div); let bgColor = divStyle.backgroundColor; let solidBg = divStyle.backgroundColor; $.rm(div); const rgb = bgColor.match(/[\d.]+/g); if (!/^rgb\(/.test(bgColor)) { const s = window.getComputedStyle(d.body); bgColor = `${s.backgroundColor} ${s.backgroundImage} ${s.backgroundRepeat} ${s.backgroundPosition}`; solidBg = s.backgroundColor; } let css = `\ :root { --xt-background: ${solidBg}; } .dialog, .suboption-list > div:last-of-type, #xt-settings-overlay .xt-detach-panel, :root.catalog-hover-expand .catalog-container:hover > .post { background: ${bgColor}; } .unread-mark-read { background-color: rgba(${rgb.slice(0, 3).join(', ')}, ${0.5 * (rgb[3] || 1)}); }\ `; if ($.luma(rgb) < 100) { css += '.watch-thread-link { --xt-watcher: #c8c8c8 }'; } Main.bgColorStyle.textContent = css; $.after($.id('fourchanx-css'), Main.bgColorStyle); Settings.applyStylingVarsDeferred(); return; }; $.onExists(d.head, g.SITE.selectors.styleSheet, function (el) { mainStyleSheet = el; if (g.SITE.software === 'yotsuba') { styleSheets = $$('link[rel="alternate stylesheet"]', d.head); } applyPreferredStyle(); $.onExists(doc, '#styleSelector', function (styleSelector) { const syncSiteStyle = function () { const selected = styleSelector.value; if (!selected) { return; } const activeSiteStyle = Settings.styleConf('siteStyle'); if (isCustomSiteStyle(activeSiteStyle)) { return; } if (activeSiteStyle !== selected && Settings.stylingSectionEnabled('siteStyle')) { const siteStyleKey = Settings.variantKey('siteStyle'); Conf[siteStyleKey] = selected; $.set(siteStyleKey, selected); } if (Conf['siteStyleHome'] && Settings.stylingSectionEnabled('siteStyle')) { Main.setSiteStyleHomeCookie(selected); } }; $.on(styleSelector, 'change', syncSiteStyle); syncSiteStyle(); applyPreferredStyle(); }); new MutationObserver(setStyle).observe(mainStyleSheet, { attributes: true, attributeFilter: ['href'] }); $.on(mainStyleSheet, 'load', setStyle); $.on(d, 'CustomSiteThemeChanged', () => { preferredStyleApplied = false; applyPreferredStyle(); setStyle(); }); return setStyle(); }); if (!mainStyleSheet) { for (var styleSheet of $$('link[rel="stylesheet"]', d.head)) { $.on(styleSheet, 'load', setStyle); } return setStyle(); } }, setSiteStyleHomeCookie(style) { const domain = location.hostname.includes('4channel.org') ? '4channel.org' : '4chan.org'; const expires = 60 * 60 * 24 * 365; const cleanupDomains = [location.hostname, domain, `.${domain}`]; const past = 'Thu, 01 Jan 1970 00:00:00 GMT'; for (const key of ['ws_style', 'nws_style']) { d.cookie = `${key}=; Expires=${past}; Path=/`; for (const cookieDomain of cleanupDomains) { d.cookie = `${key}=; Expires=${past}; Path=/; Domain=${cookieDomain}`; } d.cookie = `${key}=${style}; Max-Age=${expires}; Path=/; Domain=${domain}`; } }, initReady() { if (g.SITE.is404?.()) { if (g.VIEW === 'thread') { ThreadWatcher.set404(g.BOARD.ID, g.THREADID, function () { if (Conf['404 Redirect']) { return Redirect.navigate('thread', { boardID: g.BOARD.ID, threadID: g.THREADID, postID: +(location.hash.match(/\d+/)?.[0] || 0) } , `/${g.BOARD}/`); } }); } return; } if (g.SITE.isIncomplete?.()) { const msg = $.el('div', { innerHTML: 'The page didn't load completely.
    Some features may not work unless you reload.' }); $.on($('a', msg), 'click', () => location.reload()); new Notice('warning', msg); } if (g.VIEW === 'catalog') { Main.initCatalog(); } else if (!Index.enabled) { if (g.SITE.awaitBoard) { g.SITE.awaitBoard(Main.initThread); } else { Main.initThread(); } } else { Main.expectInitFinished = true; $.event('4chanXInitFinished'); } }, initThread() { let board; const s = g.SITE.selectors; if (board = $((s.boardFor?.[g.VIEW] || s.board))) { const threads = []; const posts = []; const errors = []; try { g.SITE.preParsingFixes?.(board); } catch (error) { } Main.addThreadsObserver = new MutationObserver(Main.addThreads); Main.addPostsObserver = new MutationObserver(Main.addPosts); Main.addThreadsObserver.observe(board, { childList: true }); Main.parseThreads($$(s.thread, board), threads, posts, errors); if (errors.length) { Main.handleErrors(errors); } if (g.VIEW === 'thread') { if (g.threadArchived) { threads[0].isArchived = true; threads[0].kill(); } g.SITE.parseThreadMetadata?.(threads[0]); } setTimeout(() => { Main.callbackNodes('Thread', threads); Main.callbackNodesDB('Post', posts, function () { for (var post of posts) QuoteThreading.insert(post); Main.expectInitFinished = true; $.event('4chanXInitFinished'); }); }, 0); } else { Main.expectInitFinished = true; $.event('4chanXInitFinished'); } }, parseThreads(threadRoots, threads, posts, errors) { for (var threadRoot of threadRoots) { var boardObj = (() => { let boardID; if (boardID = threadRoot.dataset.board) { boardID = encodeURIComponent(boardID); return g.boards[boardID] || new Board(boardID); } else { return g.BOARD; } })(); var threadID = +threadRoot.id.match(/\d*$/)[0]; if (!threadID || boardObj.threads.get(threadID)?.nodes.root) { return; } var thread = new Thread(threadID, boardObj); thread.nodes.root = threadRoot; threads.push(thread); var postRoots = $$(g.SITE.selectors.postContainer, threadRoot); if (g.SITE.isOPContainerThread) { postRoots.unshift(threadRoot); } Main.parsePosts(postRoots, thread, posts, errors); Main.addPostsObserver.observe(threadRoot, { childList: true }); } }, parsePosts(postRoots, thread, posts, errors) { for (var postRoot of postRoots) { if (postRoot.classList?.contains('qr-preview-post')) { continue; } if (!(postRoot.dataset.fullID && g.posts.get(postRoot.dataset.fullID)) && $(g.SITE.selectors.comment, postRoot)) { try { posts.push(new Post(postRoot, thread, thread.board)); } catch (err) { errors.push({ message: `Parsing of Post No.${postRoot.id.match(/\d+/)} failed. Post will be skipped.`, error: err, html: postRoot.outerHTML }); } } } }, addThreads(records) { const threadRoots = []; for (var record of records) { for (var node of record.addedNodes) { const el = node; if ((node.nodeType === Node.ELEMENT_NODE) && el.matches(g.SITE.selectors.thread)) { threadRoots.push(el); } } } if (!threadRoots.length) { return; } const threads = []; const posts = []; const errors = []; Main.parseThreads(threadRoots, threads, posts, errors); if (errors.length) { Main.handleErrors(errors); } Main.callbackNodes('Thread', threads); Main.callbackNodesDB('Post', posts, () => $.event('PostsInserted', null, records[0].target)); }, addPosts(records) { let thread; const threads = []; const threadsRM = []; const posts = []; const errors = []; for (var record of records) { thread = Get.threadFromRoot(record.target); if (!thread) { continue; } var postRoots = []; for (var node of record.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { let el = node; const postContainer = g.SITE.selectors.postContainer; if (el.matches(postContainer) || (el = $(postContainer, el))) { postRoots.push(el); } } } var n = posts.length; Main.parsePosts(postRoots, thread, posts, errors); if ((posts.length > n) && !threads.includes(thread)) { threads.push(thread); } var anyRemoved = false; for (var el of record.removedNodes) { if ((Get.postFromRoot(el)?.nodes.root === el) && !doc.contains(el)) { anyRemoved = true; break; } } if (anyRemoved && !threadsRM.includes(thread)) { threadsRM.push(thread); } } if (errors.length) { Main.handleErrors(errors); } Main.callbackNodesDB('Post', posts, function () { for (thread of threads) { $.event('PostsInserted', null, thread.nodes.root); } for (thread of threadsRM) { $.event('PostsRemoved', null, thread.nodes.root); } }); }, initCatalog() { let board; const s = g.SITE.selectors.catalog; if (s && (board = $(s.board))) { const threads = []; const errors = []; Main.addCatalogThreadsObserver = new MutationObserver(Main.addCatalogThreads); Main.addCatalogThreadsObserver.observe(board, { childList: true }); Main.parseCatalogThreads($$(s.thread, board), threads, errors); if (errors.length) { Main.handleErrors(errors); } Main.callbackNodes('CatalogThreadNative', threads); } Main.expectInitFinished = true; return $.event('4chanXInitFinished'); }, parseCatalogThreads(threadRoots, threads, errors) { for (var threadRoot of threadRoots) { try { var thread = new CatalogThreadNative(threadRoot); if (thread.thread.catalogViewNative?.nodes.root !== threadRoot) { thread.thread.catalogViewNative = thread; threads.push(thread); } } catch (err) { errors.push({ message: `Parsing of Catalog Thread No.${(threadRoot.dataset.id || threadRoot.id).match(/\d+/)} failed. Thread will be skipped.`, error: err, html: threadRoot.outerHTML }); } } }, addCatalogThreads(records) { const threadRoots = []; for (var record of records) { for (var node of record.addedNodes) { const el = node; if ((node.nodeType === Node.ELEMENT_NODE) && el.matches(g.SITE.selectors.catalog.thread)) { threadRoots.push(el); } } } if (!threadRoots.length) { return; } const threads = []; const errors = []; Main.parseCatalogThreads(threadRoots, threads, errors); if (errors.length) { Main.handleErrors(errors); } return Main.callbackNodes('CatalogThreadNative', threads); }, callbackNodes(klass, nodes) { let node; let i = 0; const cb = Callbacks[klass]; while ((node = nodes[i++])) { cb.execute(node); } }, callbackNodesDB(klass, nodes, cb) { let i = 0; const cbs = Callbacks[klass]; const fn = function () { let node; if (!(node = nodes[i])) { return false; } cbs.execute(node); return ++i % 250; }; var softTask = function () { while (fn()) { continue; } if (!nodes[i]) { if (cb) { cb(); } return; } setTimeout(softTask, 0); }; softTask(); }, handleErrors(errors) { let error; if (d.body && $.hasClass(d.body, 'fourchan_x') && !$.hasClass(doc, 'tainted')) { new Notice('error', `Error: Multiple copies of ${meta.name} or 4chan X are enabled.`); $.addClass(doc, 'tainted'); } if (g.SITE.testNativeExtension && !$.hasClass(doc, 'tainted')) { g.SITE.testNativeExtension().then(({ enabled }) => { if (enabled) { $.addClass(doc, 'tainted'); if (Conf['Disable Native Extension'] && !Main.isFirstRun) { const msg = $.el('div', { innerHTML: 'Failed to disable the native extension. You may need to block it.' }); new Notice('error', msg); } } }); } if (!(errors instanceof Array)) { error = errors; } else if (errors.length === 1) { error = errors[0]; } if (error) { new Notice('error', Main.parseError(error, Main.reportLink([error])), 15); return; } const div = $.el('div', { innerHTML: `${errors.length} errors occurred.${Main.reportLink(errors).innerHTML} [show]` }); $.on(div.lastElementChild, 'click', function () { return [this.textContent, logs.hidden] = this.textContent === 'show' ? ['hide', false] : ['show', true]; }); var logs = $.el('div', { hidden: true }); for (error of errors) { $.add(logs, Main.parseError(error)); } return new Notice('error', [div, logs], 30); }, parseError(data, reportLink) { c.error(data.message, data.error.stack); const message = $.el('div', { innerHTML: E(data.message) + ((reportLink) ? (reportLink).innerHTML : "") }); const error = $.el('div', { textContent: `${data.error.name || 'Error'}: ${data.error.message || 'see console for details'}` }); const lines = data.error.stack?.match(/\d+(?=:\d+\)?$)/mg)?.join().replace(/^/, ' at ') || ''; const context = $.el('div', { textContent: `(${meta.name} ${meta.fork} v${g.VERSION} ${platform} on ${$.engine}${lines})` }); return [message, error, context]; }, reportLink(errors) { let info; const data = errors[0]; let title = data.message; if (errors.length > 1) { title += ` (+${errors.length - 1} other errors)`; } let details = ''; const addDetails = function (text) { if (encodeURIComponent(title + details + text + '\n').length <= meta.newIssueMaxLength - meta.newIssue.replace(/%(title|details)/, '').length) { return details += text + '\n'; } }; addDetails(`\ [Please describe the steps needed to reproduce this error.] Script: ${meta.name} ${meta.fork} v${g.VERSION} ${platform} URL: ${location.href} User agent: ${navigator.userAgent}\ `); if ((platform === 'userscript') && (info = (() => { if (typeof GM !== 'undefined' && GM !== null) { return GM.info; } else { if (typeof GM_info !== 'undefined' && GM_info !== null) { return GM_info; } } })())) { addDetails(`Userscript manager: ${info.scriptHandler} ${info.version}`); } addDetails('\n' + data.error); if (data.error.stack) { addDetails(data.error.stack.replace(data.error.toString(), '').trim()); } if (data.html) { addDetails('\n`' + data.html + '`'); } details = details.replace(/file:\/{3}.+\//g, ''); const url = meta.newIssue.replace('%title', encodeURIComponent(title)).replace('%details', encodeURIComponent(details)); return { innerHTML: ` [report]` }; }, isThisPageLegit() { if (!('thisPageIsLegit' in Main)) { Main.thisPageIsLegit = g.SITE.isThisPageLegit ? g.SITE.isThisPageLegit() : !/^[45]\d\d\b/.test(document.title) && !/\.(?:json|rss)$/.test(location.pathname); } return Main.thisPageIsLegit; }, ready(cb) { return $.ready(function () { if (Main.isThisPageLegit()) { return cb(); } }); }, mounted(cb) { if (Main.isMounted) { return cb(); } else { return Main.mountedCBs.push(cb); } }, mountedCBs: [], expectInitFinished: false, isMounted: false, isFirstRun: false, jsEnabled: false, bgColorStyle: null, customSiteThemeStyle: null, addThreadsObserver: null, addPostsObserver: null, addCatalogThreadsObserver: null, features: [ ['Board Configuration', BoardConfig], ['Normalize URL', NormalizeURL], ['Delay Redirect on Post', PostRedirect], ['Captcha Configuration', CaptchaReplace], ['Image Host Rewriting', ImageHost], ['Redirect', Redirect], ['Header', Header], ['Catalog Links', CatalogLinks], ['Settings', Settings], ['Index Generator', Index], ['Disable Autoplay', AntiAutoplay], ['Announcement Hiding', PSAHiding], ['Fourchan thingies', Fourchan], ['Tinyboard Glue', Tinyboard], ['Color User IDs', IDColor], ['Highlight by User ID', IDHighlight], ['Count Posts by ID', IDPostCount], ['Custom CSS', CustomCSS], ['Thread Links', ThreadLinks], ['Linkify', Linkify], ['Reveal Spoilers', RemoveSpoilers], ['Resurrect Quotes', Quotify], ['Fetch Ghost Posts', GhostPosts], ['Filter', Filter], ['Thread Hiding Buttons', ThreadHiding], ['Reply Hiding Buttons', PostHiding], ['Recursive', Recursive], ['Strike-through Quotes', QuoteStrikeThrough], ['Quick Reply Personas', QR.persona], ['Quick Reply', QR], ['Cooldown', QR.cooldown], ['Post Jumper', PostJumper], ['Pass Link', PassLink], ['Menu', Menu], ['Index Generator (Menu)', Index.menu], ['Reply Link', ReplyLink], ['Report Link', ReportLink], ['Copy Text Link', CopyTextLink], ['Select Text Link', SelectTextLink], ['Thread Hiding (Menu)', ThreadHiding.menu], ['Reply Hiding (Menu)', PostHiding.menu], ['Delete Link', DeleteLink], ['Filter (Menu)', Filter.menu], ['Edit Link', QR.oekaki.menu], ['Download Link', DownloadLink], ['File Name Link', FileNameLink], ['Archive Link', ArchiveLink], ['Sound Link', SoundLink], ['Quote Inlining', QuoteInline], ['Quote Previewing', QuotePreview], ['Quote Backlinks', QuoteBacklink], ['Mobile Replies', MobileReplies], ['Mark Quotes of You', QuoteYou], ['Mark OP Quotes', QuoteOP], ['Mark Cross-thread Quotes', QuoteCT], ['Anonymize', Anonymize], ['Time Formatting', Time], ['Relative Post Dates', RelativeDates], ['File Info Formatting', FileInfo], ['Fappe Tyme', FappeTyme], ['Gallery', Gallery], ['Gallery (menu)', Gallery.menu], ['Download All Media', DownloadAll], ['Download All Media (menu)', DownloadAll.menu], ['Sauce', Sauce], ['Image Expansion', ImageExpand], ['Image Expansion (Menu)', ImageExpand.menu], ['Reveal Spoiler Thumbnails', RevealSpoilers], ['Image Loading', ImageLoader], ['Image Hover', ImageHover], ['Volume Control', Volume], ['WEBM Metadata', Metadata], ['Video Duration Badge', VideoDuration], ['Comment Expansion', ExpandComment], ['Thread Expansion', ExpandThread], ['Favicon', Favicon], ['Unread', Unread], ['Unread Line in Index', UnreadIndex], ['Quote Threading', QuoteThreading], ['Thread Stats', ThreadStats], ['Thread Updater', ThreadUpdater], ['Thread Watcher', ThreadWatcher], ['Thread Watcher (Menu)', ThreadWatcher.menu], ['Mark New IPs', MarkNewIPs], ['Index Navigation', Nav], ['Keybinds', Keybinds], ['Banner', Banner], ['Announcements', PSA], ['Flash Features', Flash], ['Reply Pruning', ReplyPruning], ['Mod Contact Links', ModContact], ['Restore deleted posts from archive', RestoreDeletedFromArchive], ['Mark posts on scroll bar', ScrollMarkers], ] }; $.ready(() => Main.init()); return Main; })();