SankakuAddon

Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, scrolling to image, thumbnail icons for loud/animated posts, muting/pausing videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, easier duplicate tagging/flagging. Fully configurable through the Addon config.

  1. // ==UserScript==
  2. // @name SankakuAddon
  3. // @namespace SankakuAddon
  4. // @description Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, scrolling to image, thumbnail icons for loud/animated posts, muting/pausing videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, easier duplicate tagging/flagging. Fully configurable through the Addon config.
  5. // @author sanchan
  6. // @version 1.0.14
  7. // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZHPKwRhGMc/dolYbeIgOewBOeyWKDlqFZflsFZZXGZmZ3fVzphmZpNclYuDchAXvw7+A67KlVKKlOTgL/DrIo3n3VUr8U7vPJ++7/t9et/vC6FUybC8+gGwbN9NTyRjs9m5WOMTETpoox00w3Mmp8cz/Dveb6hT9Tqhev2/78/RkjM9A+qahIcNx/WFR4VTy76jeEO4wyhqOeF94bgrBxS+ULpe5UfFhSq/KnYz6TEIqZ6xwg/Wf7BRdC3hfuEeq1Q2vs+jbhIx7ZlpqV0yu/FIM0GSGDplFinhk5BqS2Z/+wYqvimWxGPI32EFVxwFiuKNi1qWrqbUvOimfCVWVO6/8/TyQ4PV7pEkNDwEwUsvNG7B52YQfBwEwechhO/hzK75lySnkTfRN2tazx5E1+DkvKbp23C6Dp13juZqFSksM5TPw/MxtGah/Qqa56tZfa9zdAuZVXmiS9jZhT7ZH134ArhcZ+m/WStSAAAACXBIWXMAAAsSAAALEgHS3X78AAAAeElEQVQ4y2NgoCX4Xyb7H4TxqWHCo7keG5toA4CgAQebsAHYbMTlCiYibMfrCiZibcIlx0SE3/GGBRM+Gxi7HjeCMD41TESGPE5XMOGzHRsbnxcIxbsDDAMts4cbjmR7A4kpvQHkMiZCKY1QSmXBZTKedNBA1RwLAFCeNCTVhz2FAAAAAElFTkSuQmCC
  8. // @match https://chan.sankakucomplex.com/*
  9. // @match https://idol.sankakucomplex.com/*
  10. // @match https://legacy.sankakucomplex.com/*
  11. // @run-at document-start
  12. // @noframes
  13. // @grant GM.registerMenuCommand
  14. // @grant GM.addStyle
  15. // @grant GM.openInTab
  16. // @grant GM.setValue
  17. // @grant GM.getValue
  18. // @grant GM.deleteValue
  19. // @grant GM.addValueChangeListener
  20. // @grant GM_addValueChangeListener
  21. // @grant GM.setClipboard
  22. // @grant unsafeWindow
  23. // ==/UserScript==
  24.  
  25. (async function(unsafeWindow) {
  26. 'use strict';
  27.  
  28. const VERSION = 'v1.0.14';
  29.  
  30. const SVG_SIZE = 20;
  31. const SPEAKER_SVG = `<svg class="speaker_icon" width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
  32. <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  33. <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  34. <rect width="32" height="2" stroke-width=".074927"/>
  35. <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  36. <rect y="2" width="2" height="28" stroke-width=".075839"/>
  37. <rect y="30" width="32" height="2" stroke-width=".07698"/>
  38. </g>
  39. <path d="m19 11c6 5 0 10 0 10" fill="none" stroke="#1cd9ff" stroke-width="2"/>
  40. <path d="m23 9c8 7 0 14 0 14" fill="none" stroke="#1cd9ff" stroke-width="2"/>
  41. <path d="m16 23h-3l-3-3h-5v-8h5l3-3h3" fill="#ff761c"/>
  42. </svg>`;
  43.  
  44. const ANIMATED_SVG = `<svg class="animated_icon" width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
  45. <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  46. <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  47. <rect width="32" height="2" stroke-width=".074927"/>
  48. <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  49. <rect y="2" width="2" height="28" stroke-width=".075839"/>
  50. <rect y="30" width="32" height="2" stroke-width=".07698"/>
  51. </g>
  52. <path d="m18 16-10 6v-12" fill="#ff761c"/>
  53. <path d="m26 16-9 6v-12" fill="#ff761c"/>
  54. </svg>`;
  55.  
  56. const EXPLICIT_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
  57. <g>
  58. <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  59. <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  60. <rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
  61. <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  62. <rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
  63. <rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
  64. </g>
  65. <g transform="scale(.9953 1.0047)" fill="#f99" aria-label="S">
  66. <rect x="12.057" y="5.9718" width="11.052" height="2.9859" stroke-width="1.0856"/>
  67. <rect x="12.057" y="22.892" width="11.052" height="2.9859" stroke-width="1.0856"/>
  68. <rect x="12.057" y="14.432" width="9.0425" height="2.9859" stroke-width=".98198"/>
  69. <rect x="9.0425" y="5.9718" width="3.0142" height="19.906" stroke-width="1.0541"/>
  70. </g>
  71. </g>
  72. </svg>`;
  73.  
  74. const QUESTIONABLE_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
  75. <g>
  76. <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  77. <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  78. <rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
  79. <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  80. <rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
  81. <rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
  82. </g>
  83. <g transform="translate(6.9551 5.0704)" fill="#999" aria-label="S">
  84. <path d="m11.545 14.93 5 4-2 2-5-4" fill="#999"/>
  85. </g>
  86. <text x="-13" y="4" font-family="'Andika New Basic'" font-size="40px" style="line-height:1.25" xml:space="preserve"><tspan x="-13" y="4"/></text>
  87. <path d="m16 6.5a7 9.5 0 0 0-7 9.5 7 9.5 0 0 0 7 9.5 7 9.5 0 0 0 7-9.5 7 9.5 0 0 0-7-9.5zm0 1.5a5 8 0 0 1 5 8 5 8 0 0 1-5 8 5 8 0 0 1-5-8 5 8 0 0 1 5-8z" fill="#999"/>
  88. </g>
  89. </svg>`;
  90.  
  91. const SAFE_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
  92. <g>
  93. <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  94. <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  95. <rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
  96. <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  97. <rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
  98. <rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
  99. </g>
  100. <g transform="scale(.9953 1.0047)" fill="#9f9" aria-label="S">
  101. <path d="m21.586 9.7289q-0.78306-0.39548-1.4501-0.65914-0.65255-0.27684-1.2761-0.44822-0.62355-0.18456-1.2471-0.26366-0.62355-0.079096-1.3196-0.079096-0.87006 0-1.5951 0.23729-0.71055 0.23729-1.2326 0.63277-0.52204 0.39548-0.81206 0.90961-0.27552 0.51413-0.27552 1.0546 0 0.5405 0.15951 0.98871 0.17401 0.43503 0.68155 0.83052 0.50754 0.3823 1.4501 0.7646 0.94257 0.36912 2.4942 0.77779 1.4356 0.3823 2.5377 0.89643 1.1166 0.50095 1.8706 1.1601t1.1456 1.5028q0.39153 0.8437 0.39153 1.9115 0 1.3315-0.58004 2.4256-0.56554 1.081-1.5661 1.872-0.98607 0.77779-2.3057 1.2128-1.3196 0.42185-2.7987 0.42185-1.0441 0-2.0447-0.13183-0.98607-0.13183-1.8706-0.36912-0.87006-0.23729-1.6096-0.55368-0.73955-0.32957-1.2906-0.72506l0.65255-2.7025q1.4501 1.1469 2.9872 1.661 1.5516 0.51413 3.1757 0.51413 0.87006 0 1.6821-0.22411t1.4356-0.64596q0.62355-0.43503 1.0006-1.0546 0.37703-0.63278 0.37703-1.4369 0-0.51413-0.18851-0.97553-0.17401-0.47458-0.72505-0.9228-0.55104-0.44822-1.5806-0.88325-1.0296-0.44822-2.7262-0.90961-1.6966-0.4614-2.7697-1.0151-1.0731-0.55368-1.6821-1.1996-0.60904-0.65914-0.84106-1.3974-0.21752-0.75142-0.21752-1.5951 0-0.8437 0.37703-1.7797 0.39153-0.93598 1.2181-1.7269 0.82656-0.79097 2.1172-1.3051 1.2906-0.52731 3.1032-0.52731 0.84106 0 1.5661 0.065914 0.73956 0.065914 1.4211 0.21092 0.69605 0.14501 1.3631 0.36912 0.66705 0.22411 1.3776 0.5405z" fill="#9f9"/>
  102. </g>
  103. </g>
  104. </svg>`;
  105.  
  106. const RATING_SVG = {
  107. 'r18+': EXPLICIT_SVG,
  108. 'r15+': QUESTIONABLE_SVG,
  109. 'g': SAFE_SVG,
  110. };
  111.  
  112. // based on the Tag Checklist in the wiki
  113. const DEFAULT_TAGLIST =
  114. `[
  115. {
  116. "name": "People & Gender",
  117. "tags": [
  118. [
  119. ["female female_only 1girl 2girls 3girls 4girls 5girls 6+girls"],
  120. ["male male_only 1boy 2boys 3boys 4boys 5boys 6+boys"],
  121. ["futanari futanari_only 1_futanari 2_futanari 3_futanari 4_futanari 5_futanari 6+_futanari"],
  122. ["newhalf newhalf_only 1_newhalf 2_newhalf 3_newhalf 4_newhalf 5_newhalf 6+_newhalf"]
  123. ],
  124. "no_humans"
  125. ]
  126. },
  127. {
  128. "name": "Young Age",
  129. "tags": [
  130. "child loli shota toddlercon young"
  131. ]
  132. },
  133. {
  134. "name": "Androgynous",
  135. "tags": [
  136. "androgynous crossdressing genderswap trap reverse_trap"
  137. ]
  138. },
  139. {
  140. "name": "Group",
  141. "tags": [
  142. "solo duo trio quartet quintet sextet group"
  143. ]
  144. },
  145. {
  146. "name": "Relationship",
  147. "tags": [
  148. "couple siblings sisters brothers brother_and_sister twins triplets"
  149. ]
  150. },
  151. {
  152. "name": "Who/Other",
  153. "tags": [
  154. "anthropomorphization multiple_persona elf mecha monster monster_girl magical_girl fairy"
  155. ]
  156. },
  157. {
  158. "name": "Face",
  159. "tags": [
  160. "face ears eyes nose lips teeth facial_mark facial_hair beard"
  161. ]
  162. },
  163. {
  164. "name": "Upper Body",
  165. "tags": [
  166. "arms armpits armpit_crease armpit_peek back bare_shoulders breasts bust clavicle midriff navel stomach hands fingers"
  167. ]
  168. },
  169. {
  170. "name": "Lower Body",
  171. "tags": [
  172. "anus ass mound_of_venus vagina penis thighs knees feet barefoot legs bare_legs bare_thighs zettai_ryouiki toes"
  173. ]
  174. },
  175. {
  176. "name": "Breasts",
  177. "tags": [
  178. "cleavage breasts nipples areolae puffy_areolae areola_slip breasts_out_of_clothes breasts_apart underboob sideboob"
  179. ]
  180. },
  181. {
  182. "name": "Breast Size",
  183. "tags": [
  184. "pettanko small_breasts medium_breasts large_breasts huge_breasts gigantic_breasts alternate_bust_size"
  185. ]
  186. },
  187. {
  188. "name": "Skin Color",
  189. "tags": [
  190. "pale_skin dark_skin tanned shiny_skin albino",
  191. ["red_skin orange_skin yellow_skin green_skin blue_skin purple_skin pink_skin white_skin grey_skin black_skin"]
  192. ]
  193. },
  194. {
  195. "name": "Hairstyle",
  196. "tags": [
  197. "ahoge bangs blunt_bangs bob_cut double_bun drill_hair hair_over_one_eye peek-a-boo_bang ponytail side_pony_tail single_braid spiky_hair twinbraids twintails alternate_hairstyle two_side_up"
  198. ]
  199. },
  200. {
  201. "name": "Hair Length",
  202. "tags": [
  203. "very_short_hair short_hair medium_hair long_hair very_long_hair absurdly_long_hair"
  204. ]
  205. },
  206. {
  207. "name": "Hair/Eye Color",
  208. "tags": [
  209. [
  210. ["blonde_hair black_hair blue_hair brown_hair green_hair grey_hair orange_hair pink_hair purple_hair red_hair silver_hair white_hair"],
  211. ["golden_eyes black_eyes blue_eyes brown_eyes green_eyes grey_eyes orange_eyes pink_eyes purple_eyes red_eyes silver_eyes white_eyes"]
  212. ]
  213. ]
  214. },
  215. {
  216. "name": "Animal Parts",
  217. "tags": [
  218. "animal_ears bat_wings bunny_ears cat_tail wolf_ears fang horns kitsunemimi nekomimi tail inumimi wings angel_wings"
  219. ]
  220. },
  221. {
  222. "name": "Look/Other",
  223. "tags": [
  224. "chibi mole muscle pointed_ears pregnant scar curvy animal_ear_fluff fluffy_tail"
  225. ]
  226. },
  227. {
  228. "name": "Swimwear",
  229. "tags": [
  230. "bikini one-piece_swimsuit swimsuit competition_swimsuit sukumizu"
  231. ]
  232. },
  233. {
  234. "name": "Facewear",
  235. "tags": [
  236. "megane sunglasses eyewear_on_head red-framed_eyewear"
  237. ]
  238. },
  239. {
  240. "name": "Upper Body",
  241. "tags": [
  242. "sailor_collar choker shirt crop_top camisole dress bra babydoll"
  243. ]
  244. },
  245. {
  246. "name": "Lower Body",
  247. "tags": [
  248. "skirt pleated_skirt pantsu thighhighs shoes sandals socks pants shorts short_shorts"
  249. ]
  250. },
  251. {
  252. "name": "Traditional\u00A0Clothes",
  253. "tags": [
  254. "serafuku kimono kindergarten_uniform chinese_clothes"
  255. ]
  256. },
  257. {
  258. "name": "Wear/Other",
  259. "tags": [
  260. "armor suit uniform school_uniform underwear_only nude completely_nude"
  261. ]
  262. },
  263. {
  264. "name": "Actions",
  265. "tags": [
  266. "battle fighting jumping running princess_carry stretch sleeping lying flying squatting"
  267. ]
  268. },
  269. {
  270. "name": "Posture",
  271. "tags": [
  272. "all_fours arched_back back-to-back bent-over fighting_stance leaning leaning_back leaning_forward squat top-down_bottom-up"
  273. ]
  274. },
  275. {
  276. "name": "Arms",
  277. "tags": [
  278. "arms_behind_back arms_crossed arm_support arm_up arms_up arms_behind_head chin_rest outstretched_arm outstretched_arms spread_arms v_arms"
  279. ]
  280. },
  281. {
  282. "name": "Hands",
  283. "tags": [
  284. "hands_clasped hand_in_pocket hands_in_pocket hand_on_cheek hand_on_hat hand_on_head hand_on_hip hands_on_hip hand_on_shoulder holding_hands interlocked_fingers outstretched_hand"
  285. ]
  286. },
  287. {
  288. "name": "Legs",
  289. "tags": [
  290. "knees_on_chest leg_lift leg_up legs_up outstretched_leg pigeon_toed spread_legs"
  291. ]
  292. },
  293. {
  294. "name": "Sitting",
  295. "tags": [
  296. "sitting crossed_legs indian_style leg_hug seiza sitting_on_lap sitting_on_person wariza yokozuwari straddling"
  297. ]
  298. },
  299. {
  300. "name": "Standing",
  301. "tags": [
  302. "standing crossed_legs_(standing) standing_on_one_leg"
  303. ]
  304. },
  305. {
  306. "name": "Lying",
  307. "tags": [
  308. "lying on_back on_side on_stomach"
  309. ]
  310. },
  311. {
  312. "name": "Viewing Direction",
  313. "tags": [
  314. "eye_contact looking_at_viewer looking_back looking_away"
  315. ]
  316. },
  317. {
  318. "name": "Gesture",
  319. "tags": [
  320. "clenched_hand clenched_hands double_v heart_hands pinky_out pointing shushing thumbs_up \\\\m\\/ reaching salute waving cat_pose paw_pose v claw_pose double_\\\\m\\/"
  321. ]
  322. },
  323. {
  324. "name": "Facial Expressions",
  325. "tags": [
  326. "expressions expressionless ahegao anger_vein blush blush_stickers clenched_teeth closed_eyes evil naughty_face nosebleed open_mouth parted_lips pout rolleyes frown tears scream"
  327. ]
  328. },
  329. {
  330. "name": "Emotions",
  331. "tags": [
  332. "angry annoyed embarrassed happy sad scared surprised worried disappointed drunk trembling"
  333. ]
  334. },
  335. {
  336. "name": "Sex",
  337. "tags": [
  338. "sex anal clothed_sex happy_sex vaginal yaoi yuri tribadism oral"
  339. ]
  340. },
  341. {
  342. "name": "Positions",
  343. "tags": [
  344. "69 doggystyle girl_on_top cowgirl_position reverse_cowgirl_position upright_straddle missionary"
  345. ]
  346. },
  347. {
  348. "name": "Stimulation",
  349. "tags": [
  350. "buttjob footjob grinding thigh_sex tekoki caressing_testicles double_handjob masturbation crotch_rub paizuri naizuri"
  351. ]
  352. },
  353. {
  354. "name": "Oral",
  355. "tags": [
  356. "oral breast_sucking cunnilingus facesitting fellatio deepthroat :>="
  357. ]
  358. },
  359. {
  360. "name": "Groping",
  361. "tags": [
  362. "groping ass_grab breast_grab nipple_tweak self_fondle torso_grab"
  363. ]
  364. },
  365. {
  366. "name": "Group Sex",
  367. "tags": [
  368. "group_sex gangbang double_penetration orgy spitroast teamwork threesome"
  369. ]
  370. },
  371. {
  372. "name": "Insertion",
  373. "tags": [
  374. "insertion anal_insertion large_insertion stomach_bulge multiple_insertions urethral_insertion penetration nipple_penetration fingering anal_fingering"
  375. ]
  376. },
  377. {
  378. "name": "Fetishes",
  379. "tags": [
  380. "milf giantess minigirl plump fat skinny public public_nudity zenra exhibitionism voyeurism futa_on_female futa_on_male incest twincest rape about_to_be_raped molestation bestiality impregnation tentacles virgin vore"
  381. ]
  382. },
  383. {
  384. "name": "Bondage",
  385. "tags": [
  386. "bondage bdsm asphyxiation breast_bondage shibari spreader_bar suspension femdom humiliation body_writing slave spanked torture bound_arms bound_legs bound_wrists suspension"
  387. ]
  388. },
  389. {
  390. "name": "Semen",
  391. "tags": [
  392. "semen bukkake dripping_semen semen_splatter semen_pool nakadashi semen_in_anus semen_in_mouth semen_on_tongue semen_on_body semen_on_hair semen_on_lower_body semen_on_ass semen_on_vagina semen_on_upper_body semen_on_breasts semen_on_clothes ejaculation ejaculating_while_penetrated facial"
  393. ]
  394. },
  395. {
  396. "name": "Objects",
  397. "tags": [
  398. "condom used_condom sex_toy dildo vibrator"
  399. ]
  400. },
  401. {
  402. "name": "Bodily Fluids",
  403. "tags": [
  404. "blood lactation urinating saliva sweat female_ejaculation vaginal_juices"
  405. ]
  406. },
  407. {
  408. "name": "View",
  409. "tags": [
  410. "cross-section internal_cumshot x-ray"
  411. ]
  412. },
  413. {
  414. "name": "Background",
  415. "tags": [
  416. "simple_background gradient_background two-tone_background ambiguous_background blurry_background",
  417. ["white_background grey_background black_background red_background brown_background orange_background yellow_background green_background blue_background purple_background pink_background"]
  418. ]
  419. },
  420. {
  421. "name": "Placement",
  422. "tags": [
  423. "indoors outdoors rooftop city pool beach cave bedroom hallway"
  424. ]
  425. },
  426. {
  427. "name": "Nature",
  428. "tags": [
  429. "ocean river tree palm_tree wisteria lilac grass sand water",
  430. ["white_flower red_flower yellow_flower blue_flower purple_flower pink_flower"]
  431. ]
  432. },
  433. {
  434. "name": "Indoors",
  435. "tags": [
  436. "pillow bed door bed_sheet counter window curtains bathtub"
  437. ]
  438. },
  439. {
  440. "name": "Work Type",
  441. "tags": [
  442. "scan watercolor_(medium) papercraft non-web_source photoshop_(medium) sketch work_in_progress lineart"
  443. ]
  444. }
  445. ]`;
  446.  
  447. const POST_MODE_DESCRIPTIONS = {
  448. 'view': 'View',
  449. 'add-fav': 'Add to favorites',
  450. 'remove-fav': 'Remove from favorites',
  451. 'rating-s': 'Rate G',
  452. 'rating-q': 'Rate 15+',
  453. 'rating-e': 'Rate R18+',
  454. 'approve': 'Approve post',
  455. 'flag': 'Flag',
  456. 'edit-tag-script': 'Edit tag script',
  457. 'apply-tag-script': 'Apply tag script',
  458. 'choose-parent': 'Choose Parent',
  459. 'set-parent': 'Set Parent',
  460. 'edit-tags': 'Edit Tags',
  461. 'find-similar': 'Find Similar',
  462. 'delete': 'Delete Post',
  463. };
  464.  
  465. const TAG_CATEGORIES = [
  466. 'copyright',
  467. 'studio',
  468. 'character',
  469. 'artist',
  470. 'medium',
  471. 'meta',
  472. 'genre'
  473. ];
  474.  
  475. /*****************/
  476. /* compatibility */
  477. /*****************/
  478.  
  479. let IS_MONKEY = false; // Tampermonkey, Violentmonkey, Greasemonkey (all at least partially support 'GM.' functions)
  480.  
  481. if (typeof GM === 'object' && typeof GM.info === 'object') {
  482. IS_MONKEY = true;
  483.  
  484. // Greasemonkey:
  485. // doesn't have addStyle and addValueChangeListener
  486. // fetch() doesn't work with relative URLs (https://github.com/greasemonkey/greasemonkey/issues/2647), workaround: new URL('/relative/path', document.location)
  487.  
  488. // polyfill for ViolentMonkey
  489. if (!GM.addValueChangeListener && typeof GM_addValueChangeListener !== 'undefined') GM.addValueChangeListener = GM_addValueChangeListener;
  490. }
  491.  
  492. const HAS_MONKEY_STORAGE = IS_MONKEY;
  493. const HAS_MONKEY_STORAGE_LISTENER = IS_MONKEY && GM.addValueChangeListener;
  494. const HAS_MONKEY_ADD_STYLE = IS_MONKEY && GM.addStyle;
  495.  
  496. let HAS_LOCAL_STORAGE;
  497. try {
  498. HAS_LOCAL_STORAGE = !!localStorage.getItem;
  499. } catch (error) { // DOMException
  500. HAS_LOCAL_STORAGE = false;
  501. }
  502.  
  503. function add_storage_change_listener() {
  504. if (HAS_MONKEY_STORAGE_LISTENER) for (const key of Object.keys(config)) GM.addValueChangeListener(key, storage_changed);
  505. else if (HAS_LOCAL_STORAGE) window.addEventListener('storage', local_storage_changed);
  506. else show_notice(console.error, '[addon error] couldn\'t add storage change listener! No cross-tab communication possible.');
  507. }
  508.  
  509. function open_in_tab(url) {
  510. if (IS_MONKEY) GM.openInTab(url, false);
  511. else window.open(url); // requires popup permission
  512. }
  513.  
  514. function add_style(css) {
  515. if (HAS_MONKEY_ADD_STYLE) {
  516. return Promise.resolve(GM.addStyle(css)); // Violentmonkey returns a style element whereas Tampermonkey returns a Promise
  517. } else {
  518. const sheet = document.createElement('STYLE');
  519. sheet.innerText = css;
  520. document.head.appendChild(sheet);
  521. return Promise.resolve(sheet);
  522. }
  523. }
  524.  
  525. function set_clipboard(text) {
  526. if (IS_MONKEY) {
  527. GM.setClipboard(text);
  528. } else {
  529. navigator.clipboard.writeText(text).catch((err) => {
  530. show_notice(console.error, '[addon error]: Couldn\'t copy text to clipboard', err);
  531. });
  532. }
  533. }
  534.  
  535. // the site uses a ton of ancient, non-standard polyfills/prototype overrides, e.g.
  536. // Array.from(new Set([1])) returns [] instead of [1]
  537. // JSON.parse(JSON.stringify([1])) returns "[1]" instead of [1]
  538. // Array.from(s) can be replaced by [...s]
  539. // to use proper JSON we need to temporarily unbind the site's toJSON functions
  540. const toJSON_OBJECTS = [Object, Array.prototype, Number.prototype, String.prototype];
  541.  
  542. function delete_toJSONs() {
  543. const toJSON_originals = [];
  544. for (const obj of toJSON_OBJECTS) {
  545. if (obj.hasOwnProperty('toJSON')) {
  546. toJSON_originals.push({ obj, func: obj.toJSON });
  547. delete obj.toJSON;
  548. }
  549. }
  550. return toJSON_originals;
  551. }
  552.  
  553. function restore_toJSONs(toJSON_originals) {
  554. for (const { obj, func } of toJSON_originals)
  555. obj.toJSON = func;
  556. }
  557.  
  558. function JSON_stringify(obj, replacer, space) {
  559. let toJSON_originals;
  560. try {
  561. toJSON_originals = delete_toJSONs();
  562. return JSON.stringify(obj, replacer, space);
  563. } finally {
  564. restore_toJSONs(toJSON_originals);
  565. }
  566. }
  567.  
  568. // enables JSON to stringify Sets and Hotkeys
  569. function json_replacer(key, value) {
  570. if (typeof value === 'object') {
  571. if (value instanceof Set) return { t: 'Set', v: [...value] };
  572. if (value instanceof Map) return { t: 'Map', v: [...value] };
  573. if (value instanceof Hotkey) {
  574. return { t: 'Hotkey', v: {
  575. key: value.key,
  576. modifiers: value.modifiers,
  577. action: value.action_name,
  578. } };
  579. }
  580. }
  581.  
  582. return value;
  583. }
  584.  
  585. // enables JSON to parse Sets and Hotkeys
  586. function json_reviver(key, value) {
  587. if (typeof value === 'object' && value !== null) {
  588. switch (value.t) {
  589. case 'Set':
  590. return new Set(value.v);
  591. case 'Map':
  592. return new Map(value.v);
  593. case 'Hotkey':
  594. return new Hotkey(value.v.action, value.v.key, value.v.modifiers);
  595. }
  596. }
  597.  
  598. return value;
  599. }
  600.  
  601. function adjust_tag_color_css() {
  602. for (const category of TAG_CATEGORIES) {
  603. if (!config.tag_category_colors.has(category)) continue;
  604.  
  605. add_style(`
  606. .tag_button.tag-type-${category}:not(.tag_nonexistent) {
  607. color: ${config.tag_category_colors.get(category)};
  608. }
  609. `);
  610. }
  611. }
  612.  
  613. let css_vars = null;
  614. async function update_css_vars() {
  615. const style = await add_style(`
  616. :root {
  617. --thumbnail-size: ${config.thumbnail_size}px;
  618. }
  619. `);
  620. css_vars?.remove();
  621. css_vars = style;
  622. }
  623.  
  624. let applied_css = false;
  625. function adjust_css() {
  626. if (applied_css) return;
  627. applied_css = true;
  628.  
  629. // change priority of post borders (by redefining their colors after their original definition)
  630. // original: flagged < has-children < has-parent < pending < deleted
  631. // default: deleted < has-children < has-parent < pending < flagged
  632. // variants: pending < deleted < has-children < has-parent < flagged
  633. // note: pending, flagged and deleted are mutually exclusive, so this is only about their relation to has-children/parent
  634. // also note: deleted posts only show through explicit search so they don't really need a border
  635. add_style(`
  636. img.has-children { border-color: #A7DF38; }
  637. img.has-parent { border-color: #CCCC00; }
  638. ${config.post_border_style === 0 ? `
  639. img.pending { border-color: #4B4BA3; }
  640. ` : config.post_border_style === 2 ? `
  641. img.pending:is(.has-children,.has-parent) { outline: #4B4BA3 solid 2px; }
  642. ` : ''}
  643. img.flagged { border-color: #F00; }
  644. `);
  645.  
  646. /* allow enlarging tag edit box */
  647. add_style(`
  648. form#edit-form textarea#post_tags {
  649. max-width: unset;
  650. height: 255px;
  651. }`);
  652.  
  653. /* style edit box buttons */
  654. add_style(`
  655. form#edit-form button {
  656. min-width: 7em;
  657. padding-left: 0.5em;
  658. padding-right: 0.5em;
  659. margin-top: 0.5em;
  660. margin-bottom: 0.5em;
  661. }`
  662. );
  663.  
  664. /* sitefix: there can appear a small gap between the navbar <li>s and the <ul>s which partially breaks hovering */
  665. /* this issue seems to be font-dependent and is worsened by the unicode config gear icon, no matter it's size (...?!) */
  666. add_style(`
  667. div#header ul#navbar {
  668. margin-bottom: 0;
  669. padding-top: 2px;
  670. padding-bottom: 0;
  671. }
  672. div#header ul#navbar li {
  673. padding-bottom: 3px;
  674. }
  675. div#header ul#navbar li:hover > ul {
  676. height: 2.1em;
  677. }
  678. `);
  679.  
  680. /* sitefix for broken deletion page layout */
  681. add_style(`
  682. /* comparison box */
  683. #content > .deleting-post {
  684. clear: left; /* clearfix: ensure box starts below first thumbnail */
  685. overflow: auto; /* fit its content */
  686. }
  687.  
  688. /* balance margins */
  689. #content > .deleting-post {
  690. padding-top: 1em;
  691. padding-bottom: 1em;
  692. }
  693. #content > .deleting-post > div {
  694. margin-top: unset !important; /* important due to inline style */
  695. margin-bottom: unset !important;
  696. }
  697. #content > .deleting-post > ul {
  698. margin-bottom: unset;
  699. }
  700.  
  701. /* align first thumbnail with comparison box */
  702. #content > .thumb {
  703. margin-left: calc(4em + 4px);
  704. }
  705.  
  706. /* center warn tags / edit gear below thumbnails */
  707. #content .deleting-post .thumb > * {
  708. margin: auto;
  709. }
  710. `);
  711.  
  712. /* custom style for tag menu */
  713. add_style(`
  714. .tag_button {
  715. display: inline-block;
  716. border-style: solid;
  717. border-width: 1px;
  718. padding-left: 5px;
  719. padding-right: 5px;
  720. }
  721.  
  722. .tag_list, .tag_group {
  723. display: flex;
  724. flex-wrap: wrap;
  725. align-content: flex-start;
  726. align-items: flex-start;
  727. column-gap: 6px;
  728. row-gap: 3px;
  729. }
  730.  
  731. .tag_list {
  732. padding-left: 3px;
  733. padding-top: 3px;
  734. padding-bottom: 1px;
  735. }
  736.  
  737. .tag_group {
  738. padding: 2px;
  739. }
  740.  
  741. .tag_list table {
  742. margin: 0;
  743. }
  744.  
  745. .tag_group, .tag_list table {
  746. margin-left: -3px;
  747. margin-top: -2px;
  748. }
  749.  
  750. a.tag_nonexistent {
  751. color: #E00;
  752. }
  753. `);
  754.  
  755. adjust_tag_color_css();
  756.  
  757. // disable site icons when using own icons
  758. if (config.show_speaker_icon || config.show_animated_icon) {
  759. add_style(`
  760. .sound-icon, .video-icon {
  761. display: none;
  762. }
  763. `);
  764. }
  765.  
  766. update_css_vars();
  767.  
  768. if (config.custom_style) {
  769. add_style(`
  770. :root {
  771. /* 1920px - 230px sidebar - scrollbar = ~1670px */
  772. --thumbnail-image-size: calc(var(--thumbnail-size) - 30px);
  773. }
  774.  
  775. /* simple left-aligned grid */
  776. .post-gallery.post-gallery-grid .posts-container {
  777. grid-template-columns: repeat(auto-fill, var(--thumbnail-size));
  778. }
  779.  
  780. .posts-container.gap-2 {
  781. gap: 0
  782. }
  783. .post-gallery-grid .posts-container {
  784. margin-bottom: 0;
  785. }
  786.  
  787. /* center thumbnails */
  788. #popular-preview .post-preview-container {
  789. display: grid;
  790. place-items: center;
  791. }
  792. .post-gallery-grid .post-preview-container {
  793. place-items: center;
  794. }
  795.  
  796. .post-gallery${General.page === Page.Index ? ':not(.post-gallery-inline)' : ''} .post-preview {
  797. /* add outline */
  798. outline: #84848463 dashed 1px;
  799.  
  800. /* slight transparent background */
  801. background-color: #c4c4c421;
  802. /* set size */
  803. width: var(--thumbnail-size);
  804. }
  805.  
  806. /* set/override size */
  807. #post-list .posts-container .post-preview .post-preview-container {
  808. height: var(--thumbnail-size);
  809. }
  810.  
  811. /* fix thumbnail icons for post page parent/children */
  812. #post-view .post-preview .post-preview-link {
  813. display: inline-block;
  814. }
  815.  
  816. /* scale thumbnail images */
  817. .posts-container img.post-preview-image {
  818. /*
  819. * using object-fit: contain; instead will cause the borders to not fit the image
  820. * but this way will make the crossed out eyes appear tiny...
  821. */
  822. width: auto;
  823. height: auto;
  824. max-width: var(--thumbnail-image-size);
  825. max-height: var(--thumbnail-image-size);
  826. }
  827. `);
  828. }
  829.  
  830. if (config.enlarge_navbar) {
  831. add_style(`
  832. :root {
  833. --navbar-size: 4em;
  834. --subnav-size: 4em;
  835. }
  836.  
  837. /* Grow navbar size to match hover area and equalize the latter for darkmode */
  838. div#header ul#subnavbar {
  839. height: calc(var(--navbar-size) - 1em + 1px);
  840. }
  841. div#header ul#navbar li:hover > ul {
  842. height: var(--subnav-size);
  843. }
  844. `);
  845. }
  846. }
  847.  
  848.  
  849. /***************************/
  850. /* configuration functions */
  851. /***************************/
  852.  
  853. const IS_IDOL = (window.location.hostname === 'idol.sankakucomplex.com' ? 1 : 0);
  854. const HISTORY_KEY = (IS_IDOL ? 'view_history_idol' : 'view_history');
  855. const COMMON_TAGS_KEYS = ['common_tags_json', 'common_tags_json_idol'];
  856. const COMMON_TAGS_KEY = COMMON_TAGS_KEYS[IS_IDOL];
  857. const OTHER_COMMON_TAGS_KEY = COMMON_TAGS_KEYS[1 - IS_IDOL];
  858.  
  859. const HOTKEY_ACTIONS = { // actions need unique names (for serialization)
  860. postpage: {
  861. reset_size: () => {
  862. scale_image(SCALE_MODES.RESET, true);
  863. scroll_to_image();
  864. },
  865. fit_size: () => {
  866. scale_image(SCALE_MODES.FIT, true);
  867. scroll_to_image();
  868. },
  869. fit_horizontal: () => {
  870. scale_image(SCALE_MODES.HORIZONTAL, true);
  871. scroll_to_image();
  872. },
  873. fit_vertical: () => {
  874. scale_image(SCALE_MODES.VERTICAL, true);
  875. scroll_to_image();
  876. },
  877. open_similar: () => {
  878. if (PostPage.post_id === null) {
  879. show_notice(console.error, 'addon error: no post id, report to author!');
  880. return;
  881. }
  882. open_in_tab(window.location.origin + '/posts/similar?id=' + PostPage.post_id);
  883. },
  884. open_delete: () => {
  885. if (!found_delete_action) {
  886. show_notice(console.error, 'addon error: Delete action not found, no permission?');
  887. } else {
  888. if (PostPage.post_id === null) {
  889. show_notice(console.error, 'addon error: no post id, report to author!');
  890. return;
  891. }
  892. open_in_tab(window.location.origin + '/posts/delete/' + PostPage.post_id);
  893. }
  894. },
  895. add_translation: () => {
  896. unsafeWindow.Note.create();
  897. },
  898. copy_translations: () => {
  899. copy_translations();
  900. },
  901. paste_translations: () => {
  902. paste_translations();
  903. },
  904. },
  905. indexpage: {}
  906. };
  907.  
  908. for (const mode of Object.keys(POST_MODE_DESCRIPTIONS)) {
  909. HOTKEY_ACTIONS.indexpage[`${mode}_mode`] = () => select_mode(mode);
  910. }
  911.  
  912. for (let i = 1; i <= 10; i++) {
  913. HOTKEY_ACTIONS.indexpage[`select_tagscript_preset${i}`] = () => {
  914. const dropdown = document.getElementById('tagscript_presets_dropdown');
  915. dropdown.selectedIndex = i;
  916. dropdown.dispatchEvent(new Event('change'));
  917. };
  918. }
  919.  
  920. function get_hotkey_action(action_name) {
  921. for (const actions of Object.values(HOTKEY_ACTIONS)) {
  922. const action = actions[action_name];
  923. if (action) {
  924. return action;
  925. }
  926. }
  927.  
  928. throw new Error(`Hotkey ${action_name} not found`);
  929. }
  930.  
  931. class Hotkey {
  932. constructor(action_name, key, modifiers) {
  933. this.key = key;
  934. this.modifiers = modifiers ?? new Set();
  935. this.action_name = action_name;
  936. this.action = get_hotkey_action(action_name);
  937. }
  938.  
  939. call(e) {
  940. if (e.key.toLowerCase() === this.key
  941. && this.modifiers.has('ctrl') === e.ctrlKey
  942. && this.modifiers.has('alt') === e.altKey
  943. && this.modifiers.has('shift') === e.shiftKey) {
  944. this.action();
  945. }
  946. }
  947. }
  948.  
  949. const DEFAULT_CONFIG = {
  950. scroll_to_image: true,
  951. scale_image: true, // and video
  952. scale_only_downscale: false,
  953. scale_flash: false,
  954. scale_mode: 0,
  955. scale_on_resize: false,
  956. scroll_to_image_center: true,
  957. load_highres: false,
  958. highres_limit: 4000000, // bytes
  959. video_pause: false,
  960. video_mute: true,
  961. set_video_volume: false,
  962. video_volume: 50,
  963. video_controls: true,
  964. redirect_v_to_s_server: false,
  965. show_speaker_icon: false,
  966. show_animated_icon: false,
  967. show_ratings_icon: false,
  968. post_border_style: 0,
  969. custom_style: true,
  970. thumbnail_size: 208,
  971. enlarge_navbar: false,
  972. setparent_deletepotentialduplicate: false,
  973. editform_deleteuselesstags: false,
  974. hide_headerlogo: false,
  975. tag_search_buttons: true,
  976. tag_post_counts: true,
  977. or_tag_search_button: false,
  978. tag_menu: true,
  979. tag_menu_scale: '30%',
  980. tag_menu_layout: 1,
  981. common_tags_json: DEFAULT_TAGLIST,
  982. common_tags_json_idol: '[ {"name":"test tags", "tags":["tag1 tag2", ["grouped_tag1 grouped_tag2"], "tag3 tag4"] }, { "tags":[ "next_line tag5 tag6", ["grouped_tag3 grouped_tag4"] , "tag7 tag8"] }, {"name":"another\u00A0category", "tags":["t1 t2 t3"]} ]',
  983. view_history_enabled: false,
  984. view_history: new Set(),
  985. view_history_idol: new Set(),
  986. wiki_template: '',
  987. record_template:
  988. `[
  989. [
  990. "Poor Tagging - neutral",
  991. "Hello.\\n\\nPlease comply with our [[uploading rules]] when making new posts. [##]% of your posts do not have enough tags to get rid of their tagme status.\\n\\nIf you need help figuring out what tags to add, there's a great [[tag checklist]] that will give you lots of tags to add.\\n\\nUntil you correct this issue, I'll have to request that you not upload anything else. A failure to comply with our tagging standards can result in further staff action against your account.\\n\\nAs an extra bit of information, we require 13 general tags on color posts and 7 general tags on [[monochrome]] posts to avoid records such as this one.",
  992. "neutral"
  993. ]
  994. ]`,
  995. tagscript_presets:
  996. `[
  997. [
  998. "Remove potential_duplicate",
  999. "-potential_duplicate"
  1000. ],
  1001. [
  1002. "futanari -> newhalf",
  1003. "newhalf -futanari -full-package_futanari"
  1004. ]
  1005. ]`,
  1006. tag_category_collapser: false,
  1007. tag_category_collapser_style: 1,
  1008. collapsed_tag_categories: new Set(),
  1009. move_stats_to_edit_form: false,
  1010. postpage_hotkeys: [
  1011. new Hotkey('reset_size', 'h'),
  1012. new Hotkey('fit_size', 'g'),
  1013. new Hotkey('fit_horizontal', 'g', new Set(['shift'])),
  1014. new Hotkey('fit_vertical', 'g', new Set(['alt'])),
  1015. //new Hotkey('open_similar', 's'),
  1016. //new Hotkey('open_delete', 'd'), // site: shift+d
  1017. //new Hotkey('add_translation', 't'), // site: n
  1018. new Hotkey('copy_translations', 'c'),
  1019. new Hotkey('paste_translations', 'v'),
  1020. ],
  1021. indexpage_hotkeys: [
  1022. new Hotkey('set-parent_mode', 'v'),
  1023. new Hotkey('choose-parent_mode', 'c'),
  1024. new Hotkey('rating-s_mode', 's'),
  1025. new Hotkey('rating-q_mode', 'q'),
  1026. new Hotkey('rating-e_mode', 'e'),
  1027. ],
  1028. notes: [],
  1029. save_tag_categories: true,
  1030. tag_categories: new Map(),
  1031. tag_category_colors: new Map(),
  1032. use_old_wiki: false,
  1033. use_old_pools: false,
  1034. use_old_tags_index: false,
  1035. };
  1036.  
  1037. for (let i = 1; i <= 10; i++) {
  1038. const key = i < 10 ? String(i) : '0';
  1039. DEFAULT_CONFIG.indexpage_hotkeys.push(new Hotkey(`select_tagscript_preset${i}`, key));
  1040. }
  1041.  
  1042. const KEY_PREFIX = 'config.'; // used to avoid conflicts in localStorage and config element ids
  1043.  
  1044. const config = Object_clone(DEFAULT_CONFIG); // load default
  1045.  
  1046. // applied to loaded/set config entries (to e.g. fix config elements returning strings when we need numbers)
  1047. const CONFIG_FIXER = {
  1048. scale_mode: Number,
  1049. tag_menu_layout: Number,
  1050. tag_category_collapser_style: Number,
  1051. highres_limit: Number,
  1052. post_border_style: Number,
  1053. thumbnail_size: Number,
  1054. };
  1055.  
  1056. function fix_config_entry(key, value) {
  1057. const fixer = CONFIG_FIXER[key];
  1058. return (fixer !== undefined ? fixer(value) : value);
  1059. }
  1060.  
  1061. // permanently save setting (and broadcast to other tabs)
  1062. function save_setting(key, value) {
  1063. value = fix_config_entry(key, value);
  1064.  
  1065. if (HAS_MONKEY_STORAGE) {
  1066. GM.setValue(key, JSON_stringify(value, json_replacer)).catch((reason) => {
  1067. show_notice(console.error, `addon error: couldn't save setting "${key}", check console`, reason);
  1068. });
  1069.  
  1070. // use localStorage too if we don't have a change listener
  1071. if (GM.addValueChangeListener) return;
  1072. }
  1073.  
  1074. if (!HAS_LOCAL_STORAGE) {
  1075. show_notice(console.warn, `[addon] couldn't save setting "${KEY_PREFIX + key}" to localStorage. check permissions`);
  1076. return;
  1077. }
  1078.  
  1079. try {
  1080. localStorage.setItem(KEY_PREFIX + key, JSON_stringify(value, json_replacer));
  1081. } catch (error) {
  1082. show_notice(console.error, `[addon error] couldn't save setting "${KEY_PREFIX + key}" to localStorage, check console`, error);
  1083. }
  1084. }
  1085.  
  1086. async function load_config() {
  1087. const monkey_values = {};
  1088.  
  1089. if (HAS_MONKEY_STORAGE) {
  1090. const promises = [];
  1091. for (const key of Object.keys(config)) {
  1092. promises.push(GM.getValue(key).then((value) => {
  1093. monkey_values[key] = value;
  1094. }));
  1095. }
  1096.  
  1097. await Promise.all(promises);
  1098. }
  1099.  
  1100. for (const key of Object.keys(config)) {
  1101. let value = config[key]; // default already loaded
  1102.  
  1103. let stored_value = monkey_values[key];
  1104.  
  1105. if (stored_value === undefined && HAS_LOCAL_STORAGE)
  1106. stored_value = localStorage.getItem(KEY_PREFIX + key);
  1107.  
  1108. if (stored_value !== undefined && stored_value !== null) {
  1109. try {
  1110. value = JSON.parse(stored_value, json_reviver);
  1111.  
  1112. // "migrate" from old hotkey format by resetting to default
  1113. if ((key === 'postpage_hotkeys' || key === 'indexpage_hotkeys') && !Array.isArray(value)) {
  1114. reset_setting(key);
  1115. continue;
  1116. }
  1117. } catch (error) {
  1118. show_notice(console.error, `[addon error] couldn't load setting "${key}"`, error);
  1119. }
  1120. }
  1121.  
  1122. update_setting(key, value); // fire regardless
  1123. }
  1124. }
  1125.  
  1126. function storage_changed(key, old_value, new_value, remote) {
  1127. try {
  1128. if (!remote) return; // only listen to other tabs
  1129.  
  1130. if (new_value === undefined || new_value === null) {
  1131. // entry was removed, reset setting to default
  1132. update_setting(key, Object_clone(DEFAULT_CONFIG[key]));
  1133. } else {
  1134. // entry was added or changed
  1135. const value = JSON.parse(new_value, json_reviver);
  1136.  
  1137. // workaround for post view history race condition
  1138. if (key === HISTORY_KEY) {
  1139. const new_ids = Set_difference(value, config[key]);
  1140. if (new_ids.size === 0) return;
  1141.  
  1142. // integrate newly received post ids into view history
  1143. config[key] = Set_union(value, config[key]);
  1144.  
  1145. // save new view history and broadcast it to other tabs,
  1146. // which in turn might broadcast their ids back to us
  1147. save_setting(key, value);
  1148.  
  1149. // live update thumbnails
  1150. if (!is_personal_post_page()) {
  1151. for (const id of new_ids) {
  1152. const thumbs = General.thumbnail_cache.get(id);
  1153. if (thumbs === undefined) continue;
  1154.  
  1155. for (const thumb of thumbs)
  1156. fadeout_post(thumb);
  1157. }
  1158. }
  1159.  
  1160. return; // don't call update_setting()
  1161. }
  1162.  
  1163. update_setting(key, value);
  1164. }
  1165. } catch (error) {
  1166. show_notice(console.error, 'storage_changed() failed, check console', error);
  1167. }
  1168. }
  1169.  
  1170. // localStorage from other tabs changed
  1171. function local_storage_changed(e) {
  1172. if (e.storageArea !== localStorage) return;
  1173. if (e.key === null) return; // ignore external localStorage.clear() for now
  1174.  
  1175. // only look at SankakuAddon specific changes
  1176. if (!e.key.startsWith(KEY_PREFIX)) return;
  1177. const key = e.key.substring(KEY_PREFIX.length);
  1178.  
  1179. storage_changed(key, e.oldValue, e.newValue, true);
  1180. }
  1181.  
  1182. function update_setting(key, value) {
  1183. config[key] = fix_config_entry(key, value);
  1184.  
  1185. if (key === 'thumbnail_size') {
  1186. update_css_vars();
  1187. } else if (key === 'scale_on_resize') {
  1188. if (value) add_scale_on_resize_listener();
  1189. else remove_scale_on_resize_listener();
  1190. }
  1191.  
  1192. update_config_dialog_by_key(key);
  1193.  
  1194. if (key === 'hide_headerlogo') {
  1195. update_headerlogo();
  1196. } else if (key === 'collapsed_tag_categories') {
  1197. for (const category of collapser_map.keys())
  1198. collapse_tag_category(category, config.collapsed_tag_categories.has(category), false);
  1199. }
  1200. }
  1201.  
  1202. function reset_setting(key) {
  1203. if (HAS_MONKEY_STORAGE) GM.deleteValue(key);
  1204. if (HAS_LOCAL_STORAGE) localStorage.removeItem(KEY_PREFIX + key); // also delete if USE_MONKEY_STORAGE
  1205. update_setting(key, Object_clone(DEFAULT_CONFIG[key]));
  1206. }
  1207.  
  1208. function reset_config() {
  1209. for (const key of Object.keys(config)) {
  1210. // don't clear the history so the clear history button makes more sense
  1211. if (key === 'view_history' || key === 'view_history_idol') continue;
  1212. // don't reset the common tags list of the other site
  1213. if (key === OTHER_COMMON_TAGS_KEY) continue;
  1214.  
  1215. reset_setting(key);
  1216. }
  1217. }
  1218.  
  1219.  
  1220. // templates for the config dialog
  1221. const CONFIG_TABS_TEMPLATE = {
  1222. general: {
  1223. name: 'General',
  1224. categories: ['post', 'general'],
  1225. },
  1226. editing: {
  1227. name: 'Editing',
  1228. categories: ['editing'],
  1229. },
  1230. hotkeys: {
  1231. name: 'Hotkeys',
  1232. categories: ['postpage_hotkeys', 'indexpage_hotkeys'],
  1233. },
  1234. };
  1235.  
  1236. const CONFIG_CATEGORY_TEMPLATE = {
  1237. post: {
  1238. name: 'Image/Video',
  1239. entries: [
  1240. 'scroll_to_image',
  1241. 'scroll_to_image_center',
  1242. 'scale_image',
  1243. 'scale_only_downscale',
  1244. 'scale_flash',
  1245. 'scale_on_resize',
  1246. 'scale_mode',
  1247. 'load_highres',
  1248. 'video_pause',
  1249. 'video_mute',
  1250. 'set_video_volume',
  1251. 'video_controls',
  1252. 'redirect_v_to_s_server',
  1253. ],
  1254. },
  1255. general: {
  1256. name: 'General',
  1257. entries: [
  1258. 'tag_search_buttons',
  1259. 'or_tag_search_button',
  1260. 'tag_post_counts',
  1261. 'tag_category_collapser',
  1262. 'tag_category_collapser_style',
  1263. 'show_speaker_icon',
  1264. 'show_animated_icon',
  1265. 'show_ratings_icon',
  1266. 'view_history_enabled',
  1267. 'post_border_style',
  1268. 'custom_style',
  1269. 'thumbnail_size',
  1270. 'enlarge_navbar',
  1271. 'hide_headerlogo',
  1272. ],
  1273. },
  1274. editing: {
  1275. name: 'Editing',
  1276. entries: [
  1277. 'use_old_wiki',
  1278. 'use_old_pools',
  1279. 'use_old_tags_index',
  1280. 'move_stats_to_edit_form',
  1281. 'setparent_deletepotentialduplicate',
  1282. 'editform_deleteuselesstags',
  1283. 'tag_menu',
  1284. COMMON_TAGS_KEY,
  1285. 'tag_menu_layout',
  1286. 'save_tag_categories',
  1287. 'wiki_template',
  1288. 'record_template',
  1289. 'tagscript_presets',
  1290. ],
  1291. },
  1292. postpage_hotkeys: {
  1293. name: 'Post Page Hotkeys',
  1294. entries: [],
  1295. },
  1296. indexpage_hotkeys: {
  1297. name: 'Index Page Hotkeys',
  1298. entries: [],
  1299. },
  1300. };
  1301.  
  1302. // expand hotkeys
  1303. for (const [page, actions] of Object.entries(HOTKEY_ACTIONS)) {
  1304. for (const name of Object.keys(actions)) {
  1305. CONFIG_CATEGORY_TEMPLATE[`${page}_hotkeys`].entries.push(`${page}_hotkeys.${name}`);
  1306. }
  1307. }
  1308.  
  1309. const SETTINGS_TEMPLATE = {
  1310. scroll_to_image: {type: 'checkbox', desc: 'Scroll to image/video when opening post'},
  1311. scroll_to_image_center: {type: 'checkbox', desc: 'Scroll to center of image/video, else scroll to top'},
  1312. scale_image: {type: 'checkbox', desc: 'Scale image/video when opening post'},
  1313. scale_only_downscale: {type: 'checkbox', desc: 'Only downscale'},
  1314. scale_flash: {type: 'checkbox', desc: 'Also scale flash videos'},
  1315. scale_on_resize: {type: 'checkbox', desc: 'Scale image on window resize', title: 'This uses the \'scale image mode\' setting, so it doesn\'t work well when using the manual scaling actions.'},
  1316. scale_mode: {type: 'select', desc: 'Scale image/video mode: ', options: {0: 'Fit to window', 1: 'Fit horizontally', 2: 'Fit vertically'}},
  1317. load_highres: {type: 'checkbox', desc: 'Load original image if smaller than ', title: 'Set to 0 bytes to always load'},
  1318. video_pause: {type: 'checkbox', desc: 'Pause (non-flash) videos'},
  1319. video_mute: {type: 'checkbox', desc: 'Mute (non-flash) videos'},
  1320. set_video_volume: {type: 'checkbox', desc: 'Set (non-flash) video volume to: '},
  1321. video_controls: {type: 'checkbox', desc: 'Show video controls'},
  1322. redirect_v_to_s_server: {type: 'checkbox', desc: 'Redirect v.sankakucomplex.com to s.sankakucomplex.com'},
  1323. tag_post_counts: {type: 'checkbox', desc: 'Add old style post tag counts'},
  1324. tag_search_buttons: {type: 'checkbox', desc: 'Enable + - tag search buttons'},
  1325. or_tag_search_button: {type: 'checkbox', desc: 'Also add ~ tag search button'},
  1326. show_speaker_icon: {type: 'checkbox', desc: `Show ${SPEAKER_SVG} icon on thumbnail if it has audio`},
  1327. show_animated_icon: {type: 'checkbox', desc: `Show ${ANIMATED_SVG} icon on thumbnail if it is animated (${SPEAKER_SVG} overrides ${ANIMATED_SVG} )`},
  1328. show_ratings_icon: {type: 'checkbox', desc: `Show ratings icon (${SAFE_SVG}, ${QUESTIONABLE_SVG}, ${EXPLICIT_SVG}) on post thumbnails`},
  1329. view_history_enabled: {type: 'checkbox', desc: 'Fade out thumbnails of viewed posts (enables post view history)'},
  1330. post_border_style: {type: 'select', desc: 'Post border style: ', options: {0: 'Prioritize blue \'unapproved\' border', 1: 'Priorizite yellow/green \'has parent / children\' borders', 2: 'Show both borders'}},
  1331. custom_style: {type: 'checkbox', desc: 'Custom thumbnail grid style', title: 'Previously known as \'Sankaku...Something\' userstyle'},
  1332. thumbnail_size: {type: 'range', desc: 'Thumbnail size for custom style: ', min: 30, max: 330},
  1333. setparent_deletepotentialduplicate: {type: 'checkbox', desc: 'Delete potential_duplicate tag when using "Set Parent"'},
  1334. enlarge_navbar: {type: 'checkbox', desc: 'Enlarge navbar', title: 'Makes it easier to reach far options without accidentally closing the subnav.'},
  1335. editform_deleteuselesstags: {type: 'checkbox', desc: '"Save changes" button deletes useless_tags tag'},
  1336. tag_category_collapser: {type: 'checkbox', desc: 'Enable tag category collapsers on post pages'},
  1337. tag_category_collapser_style: {type: 'select', desc: 'Tag category collapser style: ', options: {0: 'Compact', 1: 'Compact category name', 2: 'Default category name'}},
  1338. hide_headerlogo: {type: 'checkbox', desc: 'Hide header logo'},
  1339. tag_menu: {type: 'checkbox', desc: 'Activate tag menu'},
  1340. [COMMON_TAGS_KEY]: {type: 'text', desc: 'Common tags list (JSON format):'},
  1341. tag_menu_layout: {type: 'select', desc: 'Tag menu layout: ', options: {0: 'Normal', 1: 'Vertically compact'}},
  1342. save_tag_categories: {type: 'checkbox', desc: 'Save tag color information for use in tag menu'},
  1343. wiki_template: {type: 'text', desc: 'Wiki template:', title: 'Text that will be be shown in a separate textarea on wiki add/edit pages so it can easily be copied.'},
  1344. record_template: {type: 'text', desc: 'Record templates (JSON format):', title: 'A list of templates to be chosen from a dropdown menu on the record add page, each entry has a title followed by the actual content.'},
  1345. tagscript_presets: {type: 'text', desc: 'Tag script presets (JSON format):', title: 'A list of tag scripts to be chosen from a dropdown menu below "Mode" (put [] to disable).'},
  1346. move_stats_to_edit_form: {type: 'checkbox', desc: 'Move post "Details" to the right of the edit form'},
  1347. use_old_wiki: {type: 'checkbox', desc: 'Use old wiki where possible'},
  1348. use_old_pools: {type: 'checkbox', desc: 'Use old pools instead of books (read-only)'},
  1349. use_old_tags_index: {type: 'checkbox', desc: 'Use old tags index'},
  1350. // TODO this is a mess
  1351. 'postpage_hotkeys.reset_size': {type: 'hotkey', desc: 'Reset Image Size'},
  1352. 'postpage_hotkeys.fit_size': {type: 'hotkey', desc: 'Fit Image'},
  1353. 'postpage_hotkeys.fit_horizontal': {type: 'hotkey', desc: 'Fit Image (Horizontal)'},
  1354. 'postpage_hotkeys.fit_vertical': {type: 'hotkey', desc: 'Fit Image (Vertical)'},
  1355. 'postpage_hotkeys.open_similar': {type: 'hotkey', desc: 'Find Similar'},
  1356. 'postpage_hotkeys.open_delete': {type: 'hotkey', desc: 'Delete Post'},
  1357. 'postpage_hotkeys.add_translation': {type: 'hotkey', desc: 'Add Translation'},
  1358. 'postpage_hotkeys.copy_translations': {type: 'hotkey', desc: 'Copy Translations'},
  1359. 'postpage_hotkeys.paste_translations': {type: 'hotkey', desc: 'Paste Translations'},
  1360. };
  1361.  
  1362. for (const [mode, desc] of Object.entries(POST_MODE_DESCRIPTIONS)) {
  1363. SETTINGS_TEMPLATE[`indexpage_hotkeys.${mode}_mode`] = {type: 'hotkey', desc: desc + ' mode'};
  1364. }
  1365.  
  1366. for (let i = 1; i <= 10; i++) {
  1367. SETTINGS_TEMPLATE[`indexpage_hotkeys.select_tagscript_preset${i}`] = {type: 'hotkey', desc: `Select Tag Script Template #${i}`};
  1368. }
  1369.  
  1370. // whether a config element's value are accessed via '.value' (or otherwise '.checked')
  1371. function is_value_element(key) {
  1372. // hardcoded elements
  1373. if (key === 'video_volume') return true;
  1374. if (key === 'highres_limit') return true;
  1375. if (key === 'tag_menu_scale') return true; // doesn't exist as an element, but it would be '.value' type
  1376.  
  1377. const type = SETTINGS_TEMPLATE[key].type;
  1378. return (type === 'select' || type === 'range' || type === 'text');
  1379. }
  1380.  
  1381. // calls f(cfg_elem, key, get_value) for each existing config element
  1382. function foreach_config_element(f) {
  1383. for (const key of Object.keys(config)) {
  1384. const cfg_elem = document.getElementById(KEY_PREFIX + key);
  1385. if (cfg_elem === null) continue;
  1386.  
  1387. if (is_value_element(key)) f(cfg_elem, key, () => cfg_elem.value);
  1388. else f(cfg_elem, key, () => cfg_elem.checked);
  1389. }
  1390. }
  1391.  
  1392. function update_config_dialog_by_key(key) {
  1393. if (key.endsWith('_hotkeys')) {
  1394. update_hotkeys(key.slice(0, -'_hotkeys'.length));
  1395. return;
  1396. }
  1397.  
  1398. const cfg_elem = document.getElementById(KEY_PREFIX + key);
  1399. if (cfg_elem !== null) {
  1400. if (is_value_element(key)) cfg_elem.value = config[key];
  1401. else cfg_elem.checked = config[key];
  1402. }
  1403. }
  1404.  
  1405. function update_config_dialog() {
  1406. for (const key of Object.keys(config)) update_config_dialog_by_key(key);
  1407. }
  1408.  
  1409. function update_headerlogo() {
  1410. hide_headerlogo(config.hide_headerlogo);
  1411. }
  1412.  
  1413. function is_config_dialog_visible() {
  1414. return document.getElementById('cfg_dialog').style.display !== 'none';
  1415. }
  1416.  
  1417. function show_config_dialog(bool) {
  1418. document.getElementById('cfg_dialog').style.display = (bool ? 'block' : 'none');
  1419. }
  1420.  
  1421.  
  1422. /********************/
  1423. /* helper functions */
  1424. /********************/
  1425.  
  1426. const EMPTY_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
  1427.  
  1428. class Tags { // thin wrapper around Set
  1429. tags;
  1430.  
  1431. constructor(tag_str) {
  1432. tag_str ??= '';
  1433. this.tags = new Set(tag_str.trim().split(/\s+/).filter(t => t.length !== 0));
  1434. }
  1435.  
  1436. static invert(tag) {
  1437. const minus = tag.startsWith('-');
  1438. return minus ? tag.substring(1) : `-${tag}`;
  1439. }
  1440.  
  1441. has(tag) {
  1442. return this.tags.has(tag);
  1443. }
  1444.  
  1445. add(tag) {
  1446. this.tags.add(tag);
  1447. this.tags.delete(Tags.invert(tag));
  1448. }
  1449.  
  1450. remove(tag) {
  1451. this.tags.delete(tag);
  1452. }
  1453.  
  1454. toggle(tag) {
  1455. if (this.has(tag)) {
  1456. this.remove(tag);
  1457. } else {
  1458. this.add(tag);
  1459. }
  1460. }
  1461.  
  1462. filter(pred) {
  1463. this.tags = new Set([...this.tags].filter(pred));
  1464. return this;
  1465. }
  1466.  
  1467. [Symbol.iterator]() {
  1468. return this.tags.values();
  1469. }
  1470.  
  1471. toArray() {
  1472. return [...this.tags];
  1473. }
  1474.  
  1475. toString() {
  1476. return [...this.tags].join(' ');
  1477. }
  1478. }
  1479.  
  1480. function set_cookie(name, value, valid_for_days = 365) {
  1481. const date = new Date();
  1482. date.setTime(date.getTime() + (valid_for_days * 24 * 60 * 60 * 1000));
  1483. document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; path=/; SameSite=Lax`;
  1484. }
  1485.  
  1486. function get_cookie(name) {
  1487. const cookies = document.cookie.split(';');
  1488. for (const cookie of cookies) {
  1489. const kv = cookie.split('=');
  1490. if (kv.length === 2 && kv[0].trim() === name) {
  1491. return decodeURIComponent(kv[1]);
  1492. }
  1493. }
  1494. return '';
  1495. }
  1496.  
  1497. function sleep(ms) {
  1498. return new Promise((resolve) => setTimeout(resolve, ms));
  1499. }
  1500.  
  1501. // helper function to modify nodes on creation
  1502. function modify_nodes(node_selector, node_modifier, root_selector) {
  1503. // MutationObserver (with childList: true, subtree: true) will observe every single node from the HTML as "added",
  1504. // but for script-inserted node trees, only the root node is counted.
  1505. // As a workaround, all subnodes of nodes matching root_selector will be checked manually
  1506.  
  1507. const observer = new MutationObserver(mutations => {
  1508. // for each added element
  1509. for (const mutation of mutations) {
  1510. for (const node of mutation.addedNodes) {
  1511. if (node.nodeType !== Node.ELEMENT_NODE) continue;
  1512.  
  1513. // check for match
  1514. if (node.matches(node_selector) && node_modifier(node, observer)) { // are we done?
  1515. observer.disconnect();
  1516. return;
  1517. }
  1518.  
  1519. // check all subnodes of nodes matching root_selector
  1520. if (root_selector && node.matches(root_selector)) {
  1521. for (const subnode of node.getElementsByTagName('*')) {
  1522. if (subnode.matches(node_selector) && node_modifier(subnode, observer)) {
  1523. observer.disconnect();
  1524. return;
  1525. }
  1526. }
  1527. }
  1528. }
  1529. }
  1530. });
  1531.  
  1532. observer.observe(document, { childList: true, subtree: true });
  1533.  
  1534. // it's possible we are too late to observe the element's construction, so look for it afterwards immediately
  1535. for (const node of document.querySelectorAll(node_selector)) {
  1536. if (node_modifier(node, observer)) {
  1537. observer.disconnect();
  1538. break;
  1539. }
  1540. }
  1541. }
  1542.  
  1543. // call adjust_css() as early as possible
  1544. function modify_css() {
  1545. const try_adjust_css = () => {
  1546. if (document.body !== null) { // wait for body to guarantee head was loaded
  1547. observer.disconnect();
  1548. adjust_css();
  1549. }
  1550. };
  1551.  
  1552. const observer = new MutationObserver(try_adjust_css);
  1553. observer.observe(document, { childList: true, subtree: true });
  1554. try_adjust_css();
  1555. }
  1556.  
  1557. function get_scrollbar_width() {
  1558. const div = document.createElement('DIV');
  1559. div.style.overflow = 'scroll';
  1560. document.body.appendChild(div);
  1561. const scrollbar_width = div.offsetWidth - div.clientWidth;
  1562. div.remove();
  1563. return scrollbar_width;
  1564. }
  1565.  
  1566. // (almost) deepclone an object
  1567. function Object_clone(obj) {
  1568. if (typeof obj === 'object') {
  1569. // shallow clone containers
  1570. if (obj instanceof Array) return [...obj];
  1571. if (obj instanceof Set) return new Set(obj);
  1572. if (obj instanceof Map) return new Map(obj);
  1573. if (obj instanceof Hotkey) return new Hotkey(obj.action_name, obj.key, new Set(obj.modifiers));
  1574.  
  1575. const new_obj = {};
  1576. for (const [key, value] of Object.entries(obj))
  1577. new_obj[key] = Object_clone(value);
  1578. return new_obj;
  1579. }
  1580.  
  1581. return obj;
  1582. }
  1583.  
  1584. function Set_difference(a, b) {
  1585. return new Set([...a].filter((x) => !b.has(x)));
  1586. }
  1587.  
  1588. function Set_union(a, b) {
  1589. return new Set([...a, ...b]);
  1590. }
  1591.  
  1592. function insert_node_after(node, ref_node) {
  1593. ref_node.parentNode.insertBefore(node, ref_node.nextSibling);
  1594. }
  1595.  
  1596. function get_resolution(obj) {
  1597. if (obj.src === 'about:blank') return null;
  1598.  
  1599. // natural size only for images, can be 0 when not yet loaded
  1600. // note: when src is changed, this can read the old size
  1601. if (obj.naturalWidth && obj.naturalHeight) {
  1602. return [obj.naturalWidth, obj.naturalHeight];
  1603. }
  1604.  
  1605. if (obj.videoWidth && obj.videoHeight) {
  1606. return [obj.videoWidth, obj.videoHeight];
  1607. }
  1608.  
  1609. return null;
  1610. }
  1611.  
  1612. function show_notice(logFunc, ...msg) {
  1613. unsafeWindow.notice?.(msg[0]);
  1614. logFunc?.(...msg);
  1615. }
  1616.  
  1617. function get_original_background_color() {
  1618. // the background-color style gets changed through the (site)script, but we need the original one
  1619. // there has to be a better way than this, right?
  1620. const current = window.getComputedStyle(document.body).getPropertyValue('background-color');
  1621. document.body.style.removeProperty('background-color');
  1622. const original = window.getComputedStyle(document.body).getPropertyValue('background-color');
  1623. document.body.style.backgroundColor = current;
  1624. return original;
  1625. }
  1626.  
  1627. // "rgb(r,g,b)" -> [int(r), int(g), int(b)]
  1628. function rgb_to_array(rgb) {
  1629. const arr = rgb.substring(rgb.indexOf('(') + 1, rgb.lastIndexOf(')')).split(/,\s*/);
  1630. for (let i = 0; i < arr.length; i++)
  1631. arr[i] = parseInt(arr[i], 10);
  1632. return arr;
  1633. }
  1634.  
  1635. function rgb_array_is_dark(rgb_array) {
  1636. let avg = 0;
  1637. for (let i = 0; i < rgb_array.length; i++)
  1638. avg += rgb_array[i];
  1639. avg /= rgb_array.length;
  1640.  
  1641. return (avg <= 128);
  1642. }
  1643.  
  1644. function rgb_array_shift(rgb, shift) {
  1645. const shifted = [];
  1646. for (let i = 0; i < 3; i++)
  1647. shifted.push(Math.min(Math.max(rgb[i] + shift, 0), 255));
  1648.  
  1649. return shifted;
  1650. }
  1651.  
  1652. // [r, g, b] -> "rgb(r,g,b)"
  1653. function rgb_array_to_rgb(rgb) {
  1654. if (rgb.length === 3)
  1655. return 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')';
  1656. return 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + rgb[3] + ')';
  1657. }
  1658.  
  1659. function is_darkmode() {
  1660. const theme = get_cookie('theme');
  1661. if (theme !== '' && Number(theme) !== 0)
  1662. return true;
  1663.  
  1664. // fallback
  1665. const rgb = rgb_to_array(get_original_background_color());
  1666. return rgb_array_is_dark(rgb);
  1667. }
  1668.  
  1669. // helper function to adjust background colors based on light or dark mode
  1670. function shifted_backgroundColor(shift) {
  1671. const rgb = rgb_to_array(get_original_background_color());
  1672. const shifted_rgb = rgb_array_shift(rgb, (is_darkmode() ? 1 : -1) * shift);
  1673. return rgb_array_to_rgb(shifted_rgb);
  1674. }
  1675.  
  1676. function create_popup_menu() {
  1677. const popup = document.createElement('DIV');
  1678. popup.style.display = 'none';
  1679. popup.style.padding = '6px 12px 6px 12px';
  1680. popup.style.border = '1px solid ' + shifted_backgroundColor(32);
  1681. popup.style.backgroundColor = get_original_background_color();
  1682. // fixed, centered div
  1683. popup.style.top = '50%';
  1684. popup.style.left = '50%';
  1685. popup.style.transform = 'translate(-50%, -50%)';
  1686. popup.style.position = 'fixed';
  1687. popup.style.zIndex = '10002';
  1688. // scroll bars if too large (resizing textareas behaves a bit weirdly on Chrome because it sets margins)
  1689. popup.style.minWidth = '30vw';
  1690. popup.style.maxWidth = '90vw';
  1691. popup.style.maxHeight = '90vh';
  1692. popup.style.overflow = 'auto';
  1693. return popup;
  1694. }
  1695.  
  1696.  
  1697. /**************************/
  1698. /* general site functions */
  1699. /**************************/
  1700.  
  1701. class Page {
  1702. static Index = Symbol('index/similar');
  1703. static Upload = Symbol('upload');
  1704. static Post = Symbol('post');
  1705. static Pool = Symbol('pool');
  1706. static WikiNew = Symbol('wiki new');
  1707. static WikiEdit = Symbol('wiki edit');
  1708. static WikiShow = Symbol('wiki show');
  1709. static Tag = Symbol('tag edit');
  1710. static TagIndex = Symbol('tag index');
  1711. static Moderate = Symbol('moderate');
  1712. static Delete = Symbol('delete');
  1713. static User = Symbol('user');
  1714. static AddRecord = Symbol('add user record');
  1715. }
  1716.  
  1717. class General {
  1718. static page;
  1719. static thumbnail_cache = new Map(); // id -> array of thumbnail elements
  1720. }
  1721.  
  1722. class IndexPage {
  1723. static has_tag_scripts;
  1724.  
  1725. static init() {
  1726. if (General.page !== Page.Index) return;
  1727.  
  1728. IndexPage.has_tag_scripts = (document.querySelector('#mode > option[value=apply-tag-script]') !== null);
  1729. }
  1730. }
  1731.  
  1732. class PostPage {
  1733. static post_id = null;
  1734. static parent_id = null;
  1735.  
  1736. static init() {
  1737. if (General.page !== Page.Post) return;
  1738.  
  1739. PostPage.post_id = document.getElementById('hidden_post_id')?.innerText;
  1740. PostPage.parent_id = document.getElementById('post_parent_id')?.value;
  1741. }
  1742. }
  1743.  
  1744. class WikiPage {
  1745. static tag = null;
  1746.  
  1747. static init() {
  1748. if (![Page.WikiNew, Page.WikiEdit, Page.WikiShow].includes(General.page)) return;
  1749.  
  1750. // /wiki/edit?title=<tag>
  1751. const params = new URL(window.location.href).searchParams;
  1752. const title = params.get('title');
  1753. if (title) {
  1754. WikiPage.tag = title;
  1755. } else {
  1756. // /wiki/<tag> or potentially /wiki/<tag>/edit
  1757. const pathname = window.location.pathname;
  1758. const a = pathname.lastIndexOf('/wiki/') + 6;
  1759. let b = pathname.indexOf('/', a + 1);
  1760. if (b === -1) b = pathname.length;
  1761. WikiPage.tag = decodeURIComponent(pathname.substring(a, b));
  1762. }
  1763. }
  1764. }
  1765.  
  1766. class PoolPage {
  1767. static pool_id = null;
  1768.  
  1769. static init() {
  1770. if (General.page !== Page.Pool) return;
  1771.  
  1772. PoolPage.pool_id = window.location.href.substring(window.location.href.lastIndexOf('/') + 1);
  1773. }
  1774. }
  1775.  
  1776.  
  1777. function get_search_url(tags) {
  1778. const url = new URL(window.location.origin);
  1779. const params = new URLSearchParams();
  1780. params.append('tags', tags);
  1781. url.search = params.toString();
  1782. return url.href;
  1783. }
  1784.  
  1785. function get_search_tags(location) {
  1786. location ??= window.location;
  1787. return new Tags(new URL(location.href).searchParams.get('tags'));
  1788. }
  1789.  
  1790. function get_username() { // won't work on every page
  1791. // read from the 'My Favorites' button (in one of the subnavs) in the navbar
  1792. for (const a of document.querySelectorAll('#navbar a')) {
  1793. if (typeof a.href !== 'string') continue;
  1794.  
  1795. for (const tag of new Tags(new URL(a.href).searchParams.get('tags'))) {
  1796. if (tag.startsWith('fav:')) {
  1797. return tag.substring('fav:'.length);
  1798. }
  1799. }
  1800. }
  1801.  
  1802. return null;
  1803. }
  1804.  
  1805. // is own uploads or favorites page
  1806. let personal_cache = null;
  1807. function is_personal_post_page() {
  1808. if (personal_cache !== null) return personal_cache;
  1809.  
  1810. const username = get_username();
  1811. if (username === null) {
  1812. personal_cache = false;
  1813. return false;
  1814. }
  1815.  
  1816. const tags = get_search_tags();
  1817. personal_cache = tags.has('fav:' + username) || tags.has('user:' + username);
  1818. return personal_cache;
  1819. }
  1820.  
  1821. function hide_headerlogo(hide) {
  1822. const logo = document.querySelector('#header .top-bar');
  1823. const news = document.querySelector('#header .carousel');
  1824. if (hide) {
  1825. if (logo) logo.style.display = 'none';
  1826. if (news) news.style.display = 'none';
  1827. } else {
  1828. if (logo) logo.style.removeProperty('display');
  1829. if (news) news.style.removeProperty('display');
  1830. }
  1831. }
  1832.  
  1833. function add_config_dialog() {
  1834. const cfg_dialog = create_popup_menu();
  1835. cfg_dialog.style.zIndex = '10010';
  1836. cfg_dialog.id = 'cfg_dialog';
  1837.  
  1838. // generate the content of the config menu
  1839. let innerDivHTML = `<div style='font-weight: bold; margin-bottom: 6px;'>SankakuAddon ${VERSION}</div>`;
  1840. // + `<hr style='margin-top: 0; margin-bottom: 2px; border:1px solid ${shifted_backgroundColor(32)};'>`;
  1841.  
  1842. // add tabs, TODO: they're ugly especially in dark mode
  1843. innerDivHTML += '<div id="cfg_tabs" style="display: flex; white-space: nowrap;">';
  1844. for (const [key, value] of Object.entries(CONFIG_TABS_TEMPLATE))
  1845. innerDivHTML += `<button id="cfg_tab_${key}" style="border-style: solid; padding: 0 2px 0 2px; margin: 0 2px 0 2px; background-color: transparent; border-bottom-width: 0; cursor: pointer">${value.name}</button>`;
  1846. innerDivHTML += '</div>';
  1847.  
  1848. // add bodies
  1849. for (const [body_key, body] of Object.entries(CONFIG_CATEGORY_TEMPLATE)) {
  1850. innerDivHTML += `<div id="cfg_body_${body_key}" style="background-color: rgba(128, 128, 128, 0.1); margin-bottom: 4px; padding: 0 4px 2px 4px;">`
  1851. + `<h5>${body.name}</h5>`;
  1852.  
  1853. // add config elements for each body
  1854. for (const key of body.entries) {
  1855. const value = SETTINGS_TEMPLATE[key];
  1856.  
  1857. if (value === undefined) {
  1858. console.error(`couldn't find SETTINGS_TEMPLATE[${key}]`);
  1859. continue;
  1860. }
  1861.  
  1862. const generate_span = () => `<span style="vertical-align: middle; ${value.title ? 'cursor:help; text-decoration: underline dashed; ' : ''}" `
  1863. + `${value.title ? `title="${value.title}"` : ''} >${value.desc}</span>`;
  1864.  
  1865. innerDivHTML += '<div>';
  1866. switch (value.type) {
  1867. case 'checkbox':
  1868. innerDivHTML += `<input id='${KEY_PREFIX}${key}' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
  1869. innerDivHTML += generate_span();
  1870. // hardcoded elements:
  1871. innerDivHTML += (key === 'set_video_volume' ? `<input id="${KEY_PREFIX}video_volume" type="number" min="0" max="100" size="4">%` : '');
  1872. innerDivHTML += (key === 'load_highres' ? `<input id="${KEY_PREFIX}highres_limit" type="number" min="0" max="4000000" size="10"> bytes` : '');
  1873. break;
  1874. case 'select':
  1875. innerDivHTML += generate_span();
  1876. innerDivHTML += `<select id="${KEY_PREFIX}${key}">`;
  1877. for (const [k, v] of Object.entries(value.options))
  1878. innerDivHTML += `<option value="${k}">${v}</option>`;
  1879. innerDivHTML += '</select>';
  1880. break;
  1881. case 'range':
  1882. innerDivHTML += generate_span();
  1883. innerDivHTML += `<input id="${KEY_PREFIX}${key}" type="range" min="${value.min}" max="${value.max}" style="vertical-align: middle;">`;
  1884. break;
  1885. case 'text':
  1886. innerDivHTML += generate_span();
  1887. innerDivHTML += `<textarea id="${KEY_PREFIX}${key}" rows=8 style='width: 100%; box-sizing: border-box; max-width: unset; margin-top: 0;'></textarea>`;
  1888. break;
  1889. case 'hotkey':
  1890. innerDivHTML += `<input id='${KEY_PREFIX}${key}_ctrl' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
  1891. innerDivHTML += '<span style="vertical-align: middle;">ctrl</span>';
  1892. innerDivHTML += `<input id='${KEY_PREFIX}${key}_alt' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
  1893. innerDivHTML += '<span style="vertical-align: middle;">alt</span>';
  1894. innerDivHTML += `<input id='${KEY_PREFIX}${key}_shift' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
  1895. innerDivHTML += '<span style="vertical-align: middle;">shift</span>';
  1896. innerDivHTML += `<input id='${KEY_PREFIX}${key}_key' type='text' maxLength='1' size='1' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
  1897. innerDivHTML += generate_span();
  1898. break;
  1899. default:
  1900. show_notice(console.error, '[addon error] CONFIG_TEMPLATE is defective!', value.type);
  1901. continue;
  1902. }
  1903. innerDivHTML += '</div>';
  1904. }
  1905.  
  1906. innerDivHTML += '</div>';
  1907. }
  1908.  
  1909. innerDivHTML += '<div style="padding: 2px">';
  1910. innerDivHTML += '<button id="config_close" style="cursor: pointer; margin-right: 6px">Close</button>';
  1911. innerDivHTML += '<button id="config_reset" style="cursor: pointer;" title="Resets all settings to default (but doesn\'t clear post history)">Reset settings</button>';
  1912. innerDivHTML += '<button id="history_clear" style="cursor: pointer;" title="Clears the post view history for the current site (chan or idol)">Clear post view history</button>';
  1913. innerDivHTML += '</div>';
  1914. innerDivHTML += '<div style="padding: 2px">&nbsp;Most settings require a page reload.</div>';
  1915.  
  1916. cfg_dialog.innerHTML = innerDivHTML;
  1917.  
  1918. // adjust inline SVG icons
  1919. for (const svg of cfg_dialog.querySelectorAll('svg')) {
  1920. svg.style.width = '1rem';
  1921. svg.style.verticalAlign = 'middle';
  1922. }
  1923.  
  1924. document.body.appendChild(cfg_dialog);
  1925.  
  1926. // hide non-default categories
  1927. for (const [other_tab_name, other_tab] of Object.entries(CONFIG_TABS_TEMPLATE)) {
  1928. if (other_tab_name !== 'general') {
  1929. for (const category of other_tab.categories)
  1930. document.getElementById(`cfg_body_${category}`).style.display = 'none';
  1931. }
  1932. }
  1933.  
  1934. // add events
  1935. document.getElementById('config_close').onclick = () => { show_config_dialog(false); return false; };
  1936. document.getElementById('config_reset').onclick = () => {
  1937. if (window.confirm('Are you sure?\nThis will reset ALL settings, including templates and the tag menu tags!')) {
  1938. reset_config();
  1939. show_notice(console.log, '[addon] reset all settings');
  1940. }
  1941. return false;
  1942. };
  1943. document.getElementById('history_clear').onclick = () => {
  1944. if (window.confirm('Are you sure you want to clear the post view history?')) {
  1945. reset_setting(HISTORY_KEY);
  1946. show_notice(console.log, '[addon] reset post view history');
  1947. }
  1948. return false;
  1949. };
  1950.  
  1951. // tab events
  1952. for (const [tab_name, tab] of Object.entries(CONFIG_TABS_TEMPLATE)) {
  1953. document.getElementById(`cfg_tab_${tab_name}`).onclick = () => {
  1954. // hide categories of other tabs
  1955. for (const [other_tab_name, other_tab] of Object.entries(CONFIG_TABS_TEMPLATE)) {
  1956. if (other_tab_name !== tab_name) {
  1957. for (const category of other_tab.categories) {
  1958. document.getElementById(`cfg_body_${category}`).style.display = 'none';
  1959. }
  1960. }
  1961. }
  1962.  
  1963. // show categories for given tab
  1964. for (const category of tab.categories) {
  1965. document.getElementById(`cfg_body_${category}`).style.display = 'block';
  1966. }
  1967. };
  1968. }
  1969.  
  1970. foreach_config_element((cfg_elem, key, get_value) => {
  1971. cfg_elem.addEventListener('change', () => {
  1972. update_setting(key, get_value());
  1973. save_setting(key, get_value());
  1974. });
  1975. });
  1976.  
  1977. // configure hotkey elements
  1978. for (const [page, actions] of Object.entries(HOTKEY_ACTIONS)) {
  1979. for (const name of Object.keys(actions)) {
  1980. for (const suffix of ['ctrl', 'alt', 'shift', 'key']) {
  1981. const cfg_elem = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_${suffix}`);
  1982. cfg_elem.addEventListener('change', () => {
  1983. const hotkeys = get_hotkeys(page);
  1984. update_setting(`${page}_hotkeys`, hotkeys);
  1985. save_setting(`${page}_hotkeys`, hotkeys);
  1986. });
  1987. }
  1988. }
  1989. }
  1990. }
  1991.  
  1992. function get_hotkeys(page) { // get from config dialog
  1993. const hotkeys = [];
  1994.  
  1995. for (const [name, action] of Object.entries(HOTKEY_ACTIONS[page])) {
  1996. const modifiers = new Set();
  1997. for (const mod of ['ctrl', 'alt', 'shift']) {
  1998. if (document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_${mod}`).checked) {
  1999. modifiers.add(mod);
  2000. }
  2001. }
  2002.  
  2003. const key = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_key`).value;
  2004. if (key.length !== 0) {
  2005. hotkeys.push(new Hotkey(name, key, modifiers));
  2006. }
  2007. }
  2008.  
  2009. return hotkeys;
  2010. }
  2011.  
  2012. function update_hotkeys(page) {
  2013. // reset all hotkey config elements
  2014. for (const name of Object.keys(HOTKEY_ACTIONS[page])) {
  2015. for (const mod of ['ctrl', 'alt', 'shift']) {
  2016. const el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_${mod}`);
  2017. if (el !== null)
  2018. el.checked = false;
  2019. }
  2020. const el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_key`);
  2021. if (el !== null)
  2022. el.value = '';
  2023. }
  2024.  
  2025. // update hotkey config elements
  2026. for (const hotkey of config[`${page}_hotkeys`]) {
  2027. const key_el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${hotkey.action_name}_key`);
  2028. if (key_el !== null)
  2029. key_el.value = hotkey.key;
  2030.  
  2031. for (const mod of ['ctrl', 'alt', 'shift']) {
  2032. const mod_el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${hotkey.action_name}_${mod}`);
  2033. if (mod_el !== null)
  2034. mod_el.checked = hotkey.modifiers.has(mod);
  2035. }
  2036. }
  2037. }
  2038.  
  2039. function add_config_button() {
  2040. const navbar = document.getElementById('navbar');
  2041. if (navbar === null) return;
  2042.  
  2043. navbar.style.whiteSpace = 'nowrap'; // hack to fit config button
  2044.  
  2045. const a = document.createElement('A');
  2046. a.href = '#';
  2047. a.onclick = () => { show_config_dialog(true); return false; };
  2048. a.innerHTML = '<span style="font-size: 110%;">⚙</span> Addon config';
  2049.  
  2050. // close when clicking outside
  2051. document.addEventListener('click', (e) => {
  2052. if (is_config_dialog_visible()) {
  2053. if (e.target.closest('#cfg_dialog') !== null)
  2054. return; // clicked inside
  2055.  
  2056. show_config_dialog(false);
  2057. e.preventDefault();
  2058. }
  2059. }, true);
  2060.  
  2061. const li = document.createElement('LI');
  2062. li.className = 'lang-select'; // match style of top bar
  2063. li.appendChild(a);
  2064. navbar.appendChild(document.createTextNode('\u00A0')); // add nbsp
  2065. navbar.appendChild(li);
  2066. }
  2067.  
  2068. function add_tag_search_buttons() {
  2069. for (const item of document.querySelectorAll('#tag-sidebar li')) {
  2070. const taglink = item.querySelector('a');
  2071. if (taglink === null) continue;
  2072.  
  2073. const tagname = get_search_tags(taglink).toString();
  2074.  
  2075. const get_click_listener = (tag) => {
  2076. return () => {
  2077. const search_field = document.getElementById('tags');
  2078. const search_tags = new Tags(search_field.value);
  2079.  
  2080. search_tags.toggle(tag);
  2081.  
  2082. search_field.value = search_tags.toString() + ' ';
  2083.  
  2084. search_field.setSelectionRange(search_field.value.length, search_field.value.length);
  2085. search_field.focus({ preventScroll: true });
  2086.  
  2087. return false;
  2088. };
  2089. };
  2090.  
  2091. const add_search_button = (tag_prefix) => {
  2092. const a = document.createElement('A');
  2093. a.href = '#';
  2094. a.innerText = tag_prefix;
  2095. a.onclick = get_click_listener((tag_prefix === '+' ? '' : tag_prefix) + tagname);
  2096.  
  2097. taglink.parentNode.insertBefore(a, taglink);
  2098. taglink.parentNode.insertBefore(document.createTextNode(' '), taglink);
  2099. };
  2100.  
  2101. add_search_button('+');
  2102. add_search_button('-');
  2103. if (config.or_tag_search_button)
  2104. add_search_button('~');
  2105. }
  2106. }
  2107.  
  2108. function add_tag_post_counts() {
  2109. for (const item of document.querySelectorAll('#tag-sidebar li')) {
  2110. const post_count = item.querySelector('.tag-link')?.dataset?.count;
  2111. if (!post_count) continue;
  2112.  
  2113. const span = document.createElement('span');
  2114. span.style.color = 'grey';
  2115. span.innerText = post_count;
  2116. item.appendChild(span);
  2117. }
  2118. }
  2119.  
  2120. // needs to run before add_tag_search_buttons
  2121. function collect_tag_categories() {
  2122. for (const category of TAG_CATEGORIES) {
  2123. for (const li of document.querySelectorAll(`.tag-type-${category}`)) {
  2124. const taglink = li.querySelector('a');
  2125. if (taglink === null) continue;
  2126.  
  2127. const tag = get_search_tags(taglink).toString();
  2128. config.tag_categories.set(tag, category);
  2129. config.tag_category_colors.set(category, window.getComputedStyle(taglink, null).getPropertyValue('color'));
  2130. }
  2131. }
  2132.  
  2133. adjust_tag_color_css(); // add another copy to reflect newly learned colors
  2134.  
  2135. if (config.save_tag_categories) {
  2136. // TODO this has the same storage race condition issue as the view history and the solution is way too complex and inefficient to implement here...
  2137. save_setting('tag_categories', config.tag_categories);
  2138. save_setting('tag_category_colors', config.tag_category_colors);
  2139. }
  2140. }
  2141.  
  2142. const collapser_map = new Map(); // category -> [collapser, tags]
  2143. const collapser_color_map = new Map(); // category -> font color
  2144.  
  2145. function collapse_tag_category(category, collapse, save = true) {
  2146. const [collapser, tags] = collapser_map.get(category);
  2147.  
  2148. const a = collapser.children[0];
  2149. const middle_div = a.children[1];
  2150.  
  2151. // collapse/expand category
  2152. if (collapse) {
  2153. for (const tag of tags)
  2154. tag.style.display = 'none';
  2155. } else {
  2156. for (const tag of tags)
  2157. tag.style.removeProperty('display');
  2158. }
  2159.  
  2160. // change collapser visuals
  2161. if ([0, 2].includes(config.tag_category_collapser_style)) { // compact style
  2162. if (collapse) {
  2163. middle_div.style.height = '0';
  2164. middle_div.style.borderTopWidth = '3px';
  2165. middle_div.style.borderBottomWidth = '3px';
  2166. middle_div.style.marginTop = '3px';
  2167. middle_div.style.marginBottom = '3px';
  2168. } else {
  2169. middle_div.style.height = '4px';
  2170. middle_div.style.borderTopWidth = '2px';
  2171. middle_div.style.borderBottomWidth = '2px';
  2172. middle_div.style.marginTop = '2px';
  2173. middle_div.style.marginBottom = '2px';
  2174. }
  2175. } else if (config.tag_category_collapser_style === 1) { // with category name
  2176. if (collapse) {
  2177. // swap border and font color
  2178. middle_div.style.color = get_original_background_color();
  2179. middle_div.style.backgroundColor = collapser_color_map.get(category);
  2180. } else {
  2181. middle_div.style.removeProperty('color');
  2182. middle_div.style.removeProperty('background-color');
  2183. }
  2184. }
  2185.  
  2186. // retain collapse state
  2187. if (collapse) config.collapsed_tag_categories.add(category);
  2188. else config.collapsed_tag_categories.delete(category);
  2189.  
  2190. if (save) save_setting('collapsed_tag_categories', config.collapsed_tag_categories);
  2191. }
  2192.  
  2193. let drag_collapse = false;
  2194. let drag_collapse_categories;
  2195.  
  2196. function drag_collapse_down(e) {
  2197. e.preventDefault();
  2198. drag_collapse = true;
  2199.  
  2200. const category = e.currentTarget.className;
  2201. drag_collapse_categories = !config.collapsed_tag_categories.has(category);
  2202.  
  2203. collapse_tag_category(category, drag_collapse_categories);
  2204. }
  2205.  
  2206. function drag_collapse_move(e) {
  2207. if (!drag_collapse) return;
  2208.  
  2209. const category = e.currentTarget.className;
  2210.  
  2211. if (drag_collapse_categories !== config.collapsed_tag_categories.has(category))
  2212. collapse_tag_category(category, drag_collapse_categories);
  2213. }
  2214.  
  2215. function drag_collapse_up() {
  2216. drag_collapse = false;
  2217. }
  2218.  
  2219. function add_tag_category_collapser() {
  2220. const tagsidebar = document.getElementById('tag-sidebar');
  2221. if (tagsidebar === null) return;
  2222.  
  2223. // remove default category names for compact styles
  2224. if (config.tag_category_collapser_style < 2) {
  2225. for (const title of document.querySelectorAll('#tag-sidebar > h6, #tag-sidebar > br')) {
  2226. title.remove();
  2227. }
  2228. }
  2229.  
  2230. const items = tagsidebar.getElementsByTagName('LI');
  2231.  
  2232. window.addEventListener('mouseup', drag_collapse_up);
  2233.  
  2234. const setup_collapser = (collapser, category, tags) => {
  2235. collapser_map.set(category, [collapser, tags]);
  2236.  
  2237. collapser.addEventListener('mousedown', drag_collapse_down);
  2238. collapser.addEventListener('mousemove', drag_collapse_move);
  2239. for (const tag of tags)
  2240. tag.addEventListener('mousemove', drag_collapse_move);
  2241. };
  2242.  
  2243. let curr_category = null;
  2244. let curr_category_tags = [];
  2245. let prev_category_collapser = null;
  2246. const categories = [];
  2247.  
  2248. for (const item of items) {
  2249. if (item.className === curr_category) {
  2250. curr_category_tags.push(item);
  2251. } else { // reached new category
  2252. if (prev_category_collapser !== null)
  2253. setup_collapser(prev_category_collapser, curr_category, [...curr_category_tags]);
  2254.  
  2255. // remember category color, workaround for color changing on hover
  2256. for (const a of item.getElementsByTagName('A')) {
  2257. collapser_color_map.set(item.className, window.getComputedStyle(a).getPropertyValue('color'));
  2258. break;
  2259. }
  2260.  
  2261. curr_category = item.className;
  2262. curr_category_tags = []; // item will be pushed in the next iteration, see warning below
  2263. categories.push(curr_category);
  2264.  
  2265. const a = document.createElement('A');
  2266. a.href = '#';
  2267. a.addEventListener('click', (e) => e.preventDefault());
  2268.  
  2269. // collapser visuals
  2270. a.style.display = 'flex';
  2271. a.style.justifyContent = 'center';
  2272. a.style.alignItems = 'center';
  2273.  
  2274. if ([0, 2].includes(config.tag_category_collapser_style)) { // compact style
  2275. a.innerHTML =
  2276. '<div style="width:40%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>' +
  2277. '<div style="width:5%; height: 4px; border-width: 2px; margin: 2px 2px 2px 2px; border-style: solid"></div>' +
  2278. '<div style="width:40%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>';
  2279. } else { // with category name
  2280. const category_name = curr_category.substring('tag-type-'.length);
  2281.  
  2282. a.style.paddingLeft = '2.5%';
  2283. a.style.paddingRight = '2.5%';
  2284. a.innerHTML =
  2285. '<div style="width:50%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>' +
  2286. `<div style="border-width: 2px; margin: 2px 2px 2px 2px; padding-left: 4px; padding-right: 4px; border-style: solid">${category_name}</div>` +
  2287. '<div style="width:50%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>';
  2288. }
  2289.  
  2290. const collapser = document.createElement('LI');
  2291. collapser.className = item.className;
  2292. collapser.appendChild(a);
  2293. prev_category_collapser = collapser;
  2294.  
  2295. // warning: modifies iterating list, current item will be processed twice
  2296. item.insertAdjacentElement('beforebegin', collapser);
  2297. }
  2298. }
  2299.  
  2300. // setup last collapser
  2301. if (prev_category_collapser !== null)
  2302. setup_collapser(prev_category_collapser, curr_category, [...curr_category_tags]);
  2303.  
  2304. for (const category of categories)
  2305. if (config.collapsed_tag_categories.has(category))
  2306. collapse_tag_category(category, true, false);
  2307. }
  2308.  
  2309. function get_thumbnail_post_id(thumb) {
  2310. return thumb.dataset.id ?? null;
  2311. }
  2312.  
  2313. const post_cache = new Map(); // returns object with tags array and rating
  2314. function get_post(post_id) {
  2315. // might not be loaded yet or exist at all (e.g on deletion page)
  2316. const internalPost = unsafeWindow.Post?.posts[post_id];
  2317. if (internalPost !== undefined) {
  2318. const post = { ...internalPost };
  2319.  
  2320. const fix_rating = (rating) => {
  2321. const rating_translation = { 's': 'g', 'q': 'r15+', 'e': 'r18+' };
  2322. if (rating_translation.hasOwnProperty(rating)) return rating_translation[rating];
  2323. return rating;
  2324. };
  2325.  
  2326. post.rating = fix_rating(post.rating);
  2327.  
  2328. return post;
  2329. }
  2330.  
  2331. const post = post_cache.get(post_id);
  2332. if (post !== undefined) return post;
  2333.  
  2334. return null;
  2335. }
  2336.  
  2337. function get_post_from_thumb(thumb) {
  2338. const img = thumb.querySelector('img.post-preview-image');
  2339. if (img === null) {
  2340. show_notice(console.error, '[addon error] thumbnail has no preview image');
  2341. return null;
  2342. }
  2343.  
  2344. const post_id = get_thumbnail_post_id(thumb);
  2345. let post = get_post(post_id);
  2346.  
  2347. if (post !== null) return post;
  2348.  
  2349. let tags = new Tags(img.dataset['auto_page']).toArray();
  2350.  
  2351. // find rating
  2352. let rating = null;
  2353. for (const tag of tags) {
  2354. if (tag.startsWith('Rating:')) {
  2355. rating = tag.substring('Rating:'.length).toLowerCase();
  2356. break;
  2357. }
  2358. }
  2359.  
  2360. // remove "match tags"
  2361. tags = tags.filter((tag) => {
  2362. return !(tag.startsWith('Rating:') || tag.startsWith('Score:') || tag.startsWith('Size:') || tag.startsWith('User:'));
  2363. });
  2364.  
  2365. post = {
  2366. tags,
  2367. rating,
  2368. };
  2369.  
  2370. post_cache.set(post_id, post);
  2371.  
  2372. return post;
  2373. }
  2374.  
  2375. function modify_thumbnail(preview_image) {
  2376. // expected layout:
  2377. // article.post-preview > div.post-preview-container > a.post-preview-link > picture > img.post-preview-image
  2378. // MutationObserver is waiting for innermost .post-preview-image to ensure all the necessary data is there
  2379.  
  2380. const thumb_a = preview_image?.closest('a.post-preview-link');
  2381. const thumb = preview_image?.closest('.post-preview');
  2382.  
  2383. if (!thumb_a) {
  2384. show_notice(console.error, '[addon error] couldn\'t find thumbnail link');
  2385. return;
  2386. } else if (!thumb) {
  2387. show_notice(console.error, '[addon error] couldn\'t find outer thumbnail');
  2388. return;
  2389. }
  2390.  
  2391. const post_id = get_thumbnail_post_id(thumb);
  2392. if (post_id === null) return;
  2393.  
  2394. // use and update thumbnail_cache
  2395. const thumbs = General.thumbnail_cache.get(post_id) ?? [];
  2396.  
  2397. const is_new = !thumbs.includes(thumb);
  2398.  
  2399. if (is_new) thumbs.push(thumb);
  2400. General.thumbnail_cache.set(post_id, thumbs);
  2401.  
  2402. if (is_new) {
  2403. override_thumbnail_click_event(thumb_a);
  2404. add_thumbnail_icons(thumb);
  2405.  
  2406. if (!is_personal_post_page() && General.page !== Page.Delete)
  2407. fadeout_viewed_post(thumb, post_id);
  2408. }
  2409. }
  2410.  
  2411. function add_thumbnail_icons(thumb) {
  2412. if (!(config.show_speaker_icon || config.show_animated_icon || config.show_ratings_icon)) return;
  2413.  
  2414. const post = get_post_from_thumb(thumb);
  2415. if (post == null) return;
  2416.  
  2417. const icons = document.createElement('SPAN');
  2418. icons.style.whiteSpace = 'nowrap';
  2419.  
  2420. if (config.show_ratings_icon) {
  2421. icons.insertAdjacentHTML('beforeend', RATING_SVG[post.rating]);
  2422. }
  2423.  
  2424. if (config.show_speaker_icon && (post.tags.includes('has_audio'))) {
  2425. icons.insertAdjacentHTML('beforeend', SPEAKER_SVG);
  2426. } else if (config.show_animated_icon && (post.tags.includes('animated') || post.tags.includes('video') || post.tags.includes('slideshow'))) {
  2427. icons.insertAdjacentHTML('beforeend', ANIMATED_SVG);
  2428. }
  2429.  
  2430. icons.className = 'thumbnail_icons';
  2431. icons.style.position = 'absolute';
  2432. icons.style.top = '2px'; // account for border
  2433. icons.style.right = '2px';
  2434. icons.style.transform = `translateX(${SVG_SIZE / 2}px) translateY(-${SVG_SIZE / 2}px)`;
  2435.  
  2436. thumb.querySelector('a').appendChild(icons);
  2437. }
  2438.  
  2439. function fadeout_post(thumb) {
  2440. if (!config.view_history_enabled) return;
  2441.  
  2442. const a = thumb.querySelector('a');
  2443. const img = thumb.querySelector('img');
  2444.  
  2445. // move box shadow from image to link, so opacity doesn't affect it
  2446. a.style.display = 'inline-block';
  2447. a.style.boxShadow = window.getComputedStyle(img).getPropertyValue('box-shadow');
  2448. img.style.removeProperty('box-shadow');
  2449.  
  2450. img.style.opacity = '20%';
  2451. for (const thumbnail_icons of thumb.getElementsByClassName('thumbnail_icons'))
  2452. thumbnail_icons.style.opacity = '20%';
  2453. }
  2454.  
  2455. function fadeout_viewed_post(thumb, id) {
  2456. if (config[HISTORY_KEY].has(id))
  2457. fadeout_post(thumb);
  2458. }
  2459.  
  2460. function configure_video(node) {
  2461. if (config.video_pause) node.pause();
  2462. if (config.set_video_volume) node.volume = config.video_volume / 100.0;
  2463. if (config.video_mute) node.muted = true;
  2464. node.controls = config.video_controls;
  2465. }
  2466.  
  2467. function useful_beta_link() { // idea and some code donated by Octopus Hugger
  2468. const betaLink = new URL('https://beta.sankakucomplex.com/');
  2469.  
  2470. if (General.page === Page.Index) {
  2471. betaLink.search = window.location.search;
  2472.  
  2473. } else if (General.page === Page.Post) {
  2474. betaLink.pathname = `/post/show/${PostPage.post_id}`;
  2475.  
  2476. } else if (General.page === Page.User) {
  2477. const username = document.querySelector('.user-show-heading > h2')?.innerText.replaceAll(' ', '_');
  2478.  
  2479. betaLink.pathname = '/user/show';
  2480. betaLink.searchParams.set('name', username);
  2481.  
  2482. } else if (General.page === Page.WikiNew) {
  2483. betaLink.pathname = '/wiki/create_article';
  2484. betaLink.searchParams.set('tagName', WikiPage.tag);
  2485.  
  2486. } else if (General.page === Page.WikiEdit) {
  2487. betaLink.pathname = '/wiki/edit_article';
  2488. betaLink.searchParams.set('tagName', WikiPage.tag);
  2489.  
  2490. } else if (General.page === Page.WikiShow) {
  2491. betaLink.pathname = '/tag/en';
  2492. betaLink.searchParams.set('tagName', WikiPage.tag);
  2493.  
  2494. } else if (General.page === Page.Pool) {
  2495. betaLink.pathname = '/books/' + PoolPage.pool_id;
  2496. }
  2497.  
  2498. // update beta link
  2499. for (const a of document.querySelectorAll('#navbar a[href="https://beta.sankakucomplex.com/"]')) {
  2500. a.href = betaLink.href;
  2501. }
  2502. }
  2503.  
  2504.  
  2505. /***********************************************/
  2506. /* main page / visually similar page functions */
  2507. /***********************************************/
  2508.  
  2509. function select_mode(mode) {
  2510. const mode_dropdown = document.getElementById('mode');
  2511.  
  2512. const old_mode = mode_dropdown.value;
  2513. mode_dropdown.value = mode;
  2514. if (!mode_dropdown.value) mode_dropdown.value = old_mode; // couldn't set mode (option doesn't exist)
  2515.  
  2516. PostModeMenu_change_override();
  2517. }
  2518.  
  2519. function add_mode_options() {
  2520. const mode_dropdown = document.getElementById('mode');
  2521. if (mode_dropdown === null) return; // not logged in
  2522.  
  2523. const add_mode_option = (text, value) => {
  2524. const option = document.createElement('option');
  2525. option.text = text;
  2526. option.value = value;
  2527. mode_dropdown.add(option);
  2528. };
  2529.  
  2530. if (IndexPage.has_tag_scripts) {
  2531. add_mode_option('Choose Parent', 'choose-parent');
  2532. add_mode_option('Set Parent', 'set-parent');
  2533. }
  2534. // add_mode_option('Edit Tags', 'edit-tags'); // TODO: currently broken due to requiring an authenticity token
  2535. add_mode_option('Find Similar', 'find-similar');
  2536. if (IndexPage.has_tag_scripts) add_mode_option('Delete Post', 'delete'); // rough estimate of user permissions
  2537.  
  2538. override_mode_change_event(mode_dropdown);
  2539.  
  2540. PostModeMenu_init_workaround(); // guarantee that 'mode' correctly changes to new modes when loading page
  2541. }
  2542.  
  2543. function add_post_edit_dialog() {
  2544. const dialog = create_popup_menu();
  2545. dialog.id = 'post_edit_dialog';
  2546. dialog.style.borderWidth = '4px';
  2547.  
  2548. // edit box, modified from the post page
  2549. dialog.innerHTML =
  2550. `<div id="SA-edit" style="display: flex; align-items: center;">
  2551. <div style="width: 300px; height: 300px; display: flex; align-items: center; justify-content: center; margin-right: 12px">
  2552. <img id="SA-edit-image" src="${EMPTY_IMAGE}">
  2553. </div>
  2554. <div>
  2555. <h5><a id="post-link" href="" target="_blank"></a></h5>
  2556. <form id="SA-edit-form" method="post" style="width: fit-content">
  2557. <input id="SA-post_old_tags" name="post[old_tags]" type="hidden" value="">
  2558. <table class="form">
  2559. <tfoot>
  2560. <tr>
  2561. <td colspan="2" style="white-space: nowrap"><input accesskey="s" name="commit" tabindex="11" type="submit" value="Save changes" style="min-width: 13.5em;">
  2562. </td>
  2563. </tr>
  2564. </tfoot>
  2565. <tbody>
  2566. <tr>
  2567. <th style="width: 10%">
  2568. <label class="block" for="SA-post_rating_questionable">Rating</label>
  2569. </th>
  2570. <td style="width: 90%">
  2571. <input id="SA-post_rating_explicit" name="post[rating]" tabindex="1" type="radio" value="explicit">
  2572. <label for="SA-post_rating_explicit">R18+</label>
  2573. <input id="SA-post_rating_questionable" name="post[rating]" tabindex="2" type="radio" value="questionable">
  2574. <label for="SA-post_rating_questionable">R15+</label>
  2575. <input checked="" id="SA-post_rating_safe" name="post[rating]" tabindex="3" type="radio" value="safe">
  2576. <label for="SA-post_rating_safe">G</label>
  2577. </td>
  2578. </tr>
  2579. <tr>
  2580. <th>
  2581. <label class="block" for="SA-post_tags">Tags</label>
  2582. </th>
  2583. <td>
  2584. <textarea cols="83" id="SA-post_tags" name="post[tags]" rows="9" spellcheck="false" tabindex="10" autocomplete="off" style="margin-top: 0.5em;" ></textarea>
  2585. </td>
  2586. </tr>
  2587. </tbody>
  2588. </table>
  2589. </form>
  2590. </div>
  2591. </div>`;
  2592.  
  2593. document.body.appendChild(dialog);
  2594. add_tag_buttons('SA-edit-form');
  2595.  
  2596. // hide when clicking outside
  2597. document.addEventListener('click', (e) => {
  2598. if (is_post_edit_dialog_visible()) {
  2599. if (e.target.closest('#post_edit_dialog, #autosuggest') !== null)
  2600. return; // clicked inside
  2601.  
  2602. show_post_edit_dialog(false);
  2603. e.preventDefault();
  2604. }
  2605. }, true);
  2606.  
  2607. enable_auto_suggest();
  2608. }
  2609.  
  2610. async function enable_auto_suggest() {
  2611. while (unsafeWindow.AutoSuggest === undefined)
  2612. await sleep(250);
  2613.  
  2614. unsafeWindow.AutoSuggest.add('#SA-post_tags');
  2615. }
  2616.  
  2617. function show_post_edit_dialog(bool) {
  2618. const dialog = document.getElementById('post_edit_dialog');
  2619. if (dialog !== null) dialog.style.display = (bool ? 'block' : 'none');
  2620. }
  2621.  
  2622. function is_post_edit_dialog_visible() {
  2623. const dialog = document.getElementById('post_edit_dialog');
  2624. return dialog?.style.display === 'block';
  2625. }
  2626.  
  2627. function open_post_edit_dialog(thumb) {
  2628. const dialog = document.getElementById('post_edit_dialog');
  2629. if (dialog === null) {
  2630. show_notice(console.error, '[addon error] tag edit popup is missing?!');
  2631. return;
  2632. }
  2633.  
  2634. const post_id = get_thumbnail_post_id(thumb);
  2635. if (post_id === null) return;
  2636.  
  2637. const a = thumb.querySelector('a');
  2638. if (a === null) return;
  2639.  
  2640. const edit_image = document.getElementById('SA-edit-image');
  2641. const post_old_tags = document.getElementById('SA-post_old_tags');
  2642. const post_tags = document.getElementById('SA-post_tags');
  2643. const form = document.getElementById('SA-edit-form');
  2644. const post_link = document.getElementById('post-link');
  2645.  
  2646. // set thumbnail image and post link
  2647. edit_image.src = EMPTY_IMAGE;
  2648. edit_image.src = thumb.querySelector('.preview').src;
  2649. post_link.href = a.href;
  2650. post_link.innerText = 'Post ' + post_id;
  2651.  
  2652. const full_rating = (rating) => ({ 'g': 'safe', 'r15+': 'questionable', 'r18+': 'explicit' }[rating]);
  2653.  
  2654. // get tags and rating
  2655. const post = get_post_from_thumb(thumb);
  2656. const tags = post.tags.join(' ');
  2657. const rating = full_rating(post.rating);
  2658.  
  2659. // set edit box contents
  2660. post_old_tags.value = tags;
  2661. post_tags.value = tags;
  2662.  
  2663. if (rating !== null) {
  2664. document.getElementById('SA-post_rating_' + rating).checked = true;
  2665. }
  2666.  
  2667. update_tag_elements();
  2668.  
  2669. form.onsubmit = async (event) => {
  2670. event.preventDefault(); // block reloading page
  2671.  
  2672. delete_useless_tags_tag();
  2673.  
  2674. const submitted_tags = new Tags(post_tags.value).toArray();
  2675. show_notice(console.log, '[addon] saving...');
  2676.  
  2677. // manually submit data
  2678. try {
  2679. const response = await fetch(new URL(`/post/update/${post_id}`, document.location.origin), {
  2680. method: 'POST',
  2681. body: new FormData(form),
  2682. redirect: 'manual', // this will otherwise err because of a https -> http redirect
  2683. });
  2684.  
  2685. // we assume success on redirect
  2686. if (response.type === 'opaqueredirect' || response.ok) {
  2687. // update local tags
  2688. post.tags = submitted_tags;
  2689. deletion_sanity_checks();
  2690.  
  2691. show_notice(console.log, '[addon] saved tags!');
  2692. } else {
  2693. show_notice(console.error, '[addon error] couldn\'t save tags!');
  2694. }
  2695. } catch (error) {
  2696. show_notice(console.error, '[addon error] network error while saving tags!', error);
  2697. }
  2698. };
  2699.  
  2700. show_post_edit_dialog(true);
  2701. }
  2702.  
  2703. function add_postmode_hotkeys() {
  2704. document.addEventListener('keydown', (e) => {
  2705. const mode_dropdown = document.getElementById('mode');
  2706. const script_presets = document.getElementById('tagscript_presets_dropdown');
  2707. if (mode_dropdown === null) return;
  2708. if (e.ctrlKey || e.altKey || e.shiftKey) return;
  2709.  
  2710. if (e.target === mode_dropdown || (script_presets !== null && e.target === script_presets)) {
  2711. e.preventDefault(); // e.g. 'v' would otherwise change to 'View Posts'
  2712. } else if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) {
  2713. return;
  2714. }
  2715.  
  2716. for (const hotkey of config.indexpage_hotkeys) {
  2717. hotkey.call(e);
  2718. }
  2719. }, true);
  2720. }
  2721.  
  2722. function PostModeMenu_init_workaround() {
  2723. // issue: new post modes can reset on page load if they were added too late
  2724. // reason: on page load, PostModeMenu.init reads the "mode" cookie, tries to set mode_dropdown.value, then
  2725. // calls PostModeMenu.change, which sets the cookie to mode_dropdown.value.
  2726. // so if the new modes aren't added yet, mode_dropdown.value and the "mode" cookie will both reset
  2727. // solution: safe mode in a separate 'backup' cookie and set the "mode" cookie and mode_dropdown after new modes were added
  2728. const mode = get_cookie('addon_mode');
  2729. if (mode !== '') {
  2730. set_cookie('mode', mode, 7);
  2731. document.getElementById('mode').value = mode;
  2732. }
  2733.  
  2734. PostModeMenu_change_override();
  2735. }
  2736.  
  2737. async function PostModeMenu_change_override() {
  2738. const mode_dropdown = document.getElementById('mode');
  2739. if (mode_dropdown === null) {
  2740. show_notice(console.error, '[addon error] PostModeMenu_change_override() couldn\'t find mode dropdown?!');
  2741. return;
  2742. }
  2743.  
  2744. // setting mode failed, possible when changing to account with lower permissions
  2745. if (!mode_dropdown.value) {
  2746. show_notice(console.error, '[addon error] invalid mode, resetting to \'view\'');
  2747. mode_dropdown.value = 'view';
  2748. set_cookie('mode', 'view', 7);
  2749. }
  2750.  
  2751. // try to guarantee sitescript has loaded TODO: future note: potential infinite loop
  2752. while (unsafeWindow.PostModeMenu === undefined || unsafeWindow.Cookie === undefined || unsafeWindow.$ === undefined)
  2753. await sleep(100);
  2754.  
  2755. unsafeWindow.PostModeMenu.change();
  2756.  
  2757. const s = mode_dropdown.value;
  2758.  
  2759. set_cookie('addon_mode', s, 7); // set 'backup' cookie
  2760.  
  2761. const darkmode = is_darkmode();
  2762. if (s === 'add-fav') {
  2763. // FFFFAA, original. darkmode: luminance 40
  2764. document.body.style.backgroundColor = (darkmode ? '#505000' : '#FFA');
  2765. } else if (s === 'remove-fav') {
  2766. // FFFFAA -> FFEEAA, slightly more orange. darkmode: luminance 40
  2767. document.body.style.backgroundColor = (darkmode ? '#504000' : '#FEA');
  2768. } else if (s === 'apply-tag-script') {
  2769. // AA33AA -> FFDDFF, weaken color intensity. darkmode: luminance 40
  2770. document.body.style.backgroundColor = (darkmode ? '#500050' : '#FDF');
  2771. } else if (s === 'approve') {
  2772. // 2266AA -> FFDDFF, increase contrast to unapproved posts. darkmode: luminance 40
  2773. document.body.style.backgroundColor = (darkmode ? '#500050' : '#FDF');
  2774. } else if (s === 'choose-parent') {
  2775. document.body.style.backgroundColor = (darkmode ? '#464600' : '#FFD');
  2776. } else if (s === 'set-parent') {
  2777. if (get_cookie('chosen-parent') === '') {
  2778. show_notice(console.warn, '[addon] Choose parent first!');
  2779. select_mode('choose-parent');
  2780. } else {
  2781. document.body.style.backgroundColor = (darkmode ? '#005050' : '#DFF');
  2782. }
  2783. } else if (s === 'edit-tags') {
  2784. document.body.style.backgroundColor = (darkmode ? '#006400' : '#3A3');
  2785. } else if (s === 'find-similar') {
  2786. document.body.style.removeProperty('background-color');
  2787. } else if (s === 'delete') {
  2788. document.body.style.backgroundColor = 'rgba(147, 0, 0, 0.7)';
  2789. }
  2790. }
  2791.  
  2792. function PostModeMenu_click_override(event) {
  2793. const thumb_a = event.currentTarget;
  2794. const thumb = thumb_a.closest('.post-preview');
  2795. const post_id = get_thumbnail_post_id(thumb);
  2796.  
  2797. if (unsafeWindow.PostModeMenu.click(post_id))
  2798. return true; // view mode, let it click
  2799.  
  2800. const mode_dropdown = document.getElementById('mode');
  2801. const s = mode_dropdown.value;
  2802.  
  2803. if (s === 'choose-parent') {
  2804. set_cookie('chosen-parent', post_id);
  2805. mode_dropdown.value = 'set-parent';
  2806. PostModeMenu_change_override();
  2807. } else if (s === 'set-parent') {
  2808. const parent_id = get_cookie('chosen-parent');
  2809. unsafeWindow.TagScript.run(post_id, 'parent:' + parent_id + (config.setparent_deletepotentialduplicate ? ' -potential_duplicate' : ''));
  2810. } else if (s === 'edit-tags') {
  2811. open_post_edit_dialog(thumb);
  2812. } else if (s === 'find-similar') {
  2813. open_in_tab(window.location.origin + '/posts/similar?id=' + post_id);
  2814. } else if (s === 'delete') {
  2815. open_in_tab(window.location.origin + `/posts/${post_id}/delete`);
  2816. }
  2817.  
  2818. return false;
  2819. }
  2820.  
  2821. function override_mode_change_event(mode_dropdown) {
  2822. mode_dropdown.removeAttribute('onchange');
  2823. mode_dropdown.onchange = PostModeMenu_change_override;
  2824. }
  2825.  
  2826. function override_thumbnail_click_event(thumb_a) {
  2827. thumb_a.removeAttribute('onclick');
  2828. thumb_a.onclick = PostModeMenu_click_override;
  2829. }
  2830.  
  2831. /***********************/
  2832. /* post page functions */
  2833. /***********************/
  2834.  
  2835. // TODO put in PostPage
  2836. // original post/parent ids
  2837. let image_data = null;
  2838. let resize_timer;
  2839. let tag_update_timer;
  2840. // set by find_actions_list():
  2841. let found_delete_action = false;
  2842.  
  2843. let done_scrolling = false;
  2844. function is_done_scrolling() {
  2845. return !config.scroll_to_image || done_scrolling;
  2846. }
  2847.  
  2848. class TagMenuScaler {
  2849. static #mouse_moved = false;
  2850.  
  2851. static mousedown(e) {
  2852. e.preventDefault();
  2853. TagMenuScaler.#mouse_moved = false;
  2854. window.addEventListener('mousemove', TagMenuScaler.mousemove);
  2855. window.addEventListener('mouseup', TagMenuScaler.mouseup);
  2856. }
  2857.  
  2858. static mousemove(e) {
  2859. e.preventDefault();
  2860. TagMenuScaler.#mouse_moved = true;
  2861. TagMenuScaler.set_scale(e, false);
  2862. }
  2863.  
  2864. static mouseup(e) {
  2865. e.preventDefault();
  2866. if (TagMenuScaler.#mouse_moved) TagMenuScaler.set_scale(e, true);
  2867.  
  2868. window.removeEventListener('mousemove', TagMenuScaler.mousemove);
  2869. window.removeEventListener('mouseup', TagMenuScaler.mouseup);
  2870. }
  2871.  
  2872. static set_scale(e, save) {
  2873. const tag_menu = document.getElementById('tag_menu');
  2874. if (tag_menu === null) return;
  2875.  
  2876. const yFromBottom = window.innerHeight - e.clientY;
  2877. let yPercentfromBottom = (100.0 * (yFromBottom / window.innerHeight));
  2878. yPercentfromBottom = Math.min(Math.max(yPercentfromBottom, 5), 95) + '%';
  2879.  
  2880. tag_menu.style.height = yPercentfromBottom;
  2881.  
  2882. if (save) save_setting('tag_menu_scale', yPercentfromBottom);
  2883. }
  2884. }
  2885.  
  2886. function add_tag_menu() {
  2887. if (document.getElementById('post_tags') === null) return; // not logged in
  2888.  
  2889. const tag_menu = document.createElement('DIV');
  2890. tag_menu.id = 'tag_menu';
  2891. tag_menu.style.display = 'none';
  2892. tag_menu.style.width = '100%';
  2893. tag_menu.style.height = config.tag_menu_scale;
  2894. tag_menu.style.position = 'fixed';
  2895. tag_menu.style.bottom = '0';
  2896. tag_menu.style.overflow = 'auto';
  2897. tag_menu.style.backgroundColor = get_original_background_color();
  2898. tag_menu.style.zIndex = '10001';
  2899. document.body.appendChild(tag_menu);
  2900.  
  2901. // the inner div ensures tag_menu_close button doesn't scroll with the content
  2902. tag_menu.innerHTML = '<div style="width: calc(100% - 2px); height: 100%; overflow: auto;"><span id="common_tags"></span>Current Tags:<span id="current_tags"></span></div>';
  2903.  
  2904. const tag_menu_scaler = document.createElement('DIV');
  2905. tag_menu_scaler.id = 'tag_menu_scaler';
  2906. tag_menu_scaler.style.width = '100%';
  2907. tag_menu_scaler.style.height = '6px';
  2908. tag_menu_scaler.style.backgroundColor = shifted_backgroundColor(32);
  2909. tag_menu_scaler.style.position = 'absolute';
  2910. tag_menu_scaler.style.top = '0';
  2911. tag_menu_scaler.style.cursor = 'ns-resize';
  2912. tag_menu_scaler.style.zIndex = '10000';
  2913. tag_menu_scaler.addEventListener('mousedown', TagMenuScaler.mousedown);
  2914. tag_menu.appendChild(tag_menu_scaler);
  2915. tag_menu.style.paddingTop = tag_menu_scaler.style.height; // since tag_menu_scaler floats above the tags
  2916.  
  2917. const create_tag_menu_button = function(id, text) {
  2918. const button = document.createElement('DIV');
  2919. button.id = id;
  2920. button.style.border = '1px solid ' + shifted_backgroundColor(32);
  2921. button.style.width = '24px';
  2922. button.style.height = '24px';
  2923. button.style.position = 'absolute';
  2924. button.style.textAlign = 'center';
  2925. button.style.cursor = 'pointer';
  2926. button.style.backgroundColor = shifted_backgroundColor(16);
  2927. button.innerHTML = `<span style="width: 100%; display: block; position: absolute; top: 50%; left: 50%; transform: translateX(-50%) translateY(-50%);">${text}</span>`;
  2928. button.style.zIndex = '10001';
  2929. return button;
  2930. };
  2931.  
  2932. const tag_menu_close = create_tag_menu_button('tag_menu_close', 'X');
  2933. tag_menu_close.style.top = '0';
  2934. tag_menu_close.style.right = '0';
  2935. tag_menu_close.onclick = () => { show_tag_menu(false); return false; };
  2936. tag_menu.appendChild(tag_menu_close);
  2937.  
  2938. const tag_menu_open = create_tag_menu_button('tag_menu_open', '«');
  2939. tag_menu_open.style.position = 'fixed';
  2940. tag_menu_open.style.right = '0';
  2941. tag_menu_open.style.bottom = '0';
  2942. tag_menu_open.onclick = () => { show_tag_menu(true); update_tag_menu(); return false; };
  2943. document.body.appendChild(tag_menu_open);
  2944.  
  2945. const tag_menu_save = create_tag_menu_button('tag_menu_save', 'Save changes');
  2946. tag_menu_save.style.top = '0';
  2947. tag_menu_save.style.right = '36px';
  2948. tag_menu_save.style.width = '140px';
  2949. tag_menu_save.style.fontWeight = 'bold';
  2950. tag_menu_save.addEventListener('click', (e) => {
  2951. e.preventDefault();
  2952. delete_useless_tags_tag();
  2953. document.getElementById('edit-form').submit();
  2954. });
  2955. tag_menu.appendChild(tag_menu_save);
  2956. }
  2957.  
  2958. function update_tag_menu(skip_common_tags = false) {
  2959. if (document.getElementById('post_tags') === null) return; // not logged in
  2960.  
  2961. const common_tags_elem = document.getElementById('common_tags');
  2962. const current_tags_elem = document.getElementById('current_tags');
  2963.  
  2964. // tag menu disabled
  2965. if (common_tags_elem === null || current_tags_elem === null)
  2966. return;
  2967.  
  2968. if (config.tag_menu_layout === 1) {
  2969. common_tags_elem.style.display = 'grid';
  2970. common_tags_elem.style.gridTemplateColumns = 'fit-content(5%) auto';
  2971. } else {
  2972. common_tags_elem.style.removeProperty('display');
  2973. }
  2974.  
  2975. const create_tag_button = function(tag, skip_common_tags_update = false) {
  2976. const a = document.createElement('A');
  2977. a.href = '#';
  2978. a.style.backgroundColor = (is_darkmode() ? '#000' : '#FFF'); // more contrast for tag buttons
  2979. a.classList.add('tag_button');
  2980. if (config.tag_categories.has(tag)) a.classList.add(`tag-type-${config.tag_categories.get(tag)}`);
  2981. if (!get_post_tags().has(tag)) a.classList.add('tag_nonexistent');
  2982.  
  2983. a.onclick = function(e) {
  2984. if (e.ctrlKey) {
  2985. open_in_tab(window.location.origin + '/wiki/' + tag);
  2986. return false;
  2987. }
  2988.  
  2989. if (get_post_tags().has(tag)) {
  2990. remove_post_tag(tag, skip_common_tags_update);
  2991. a.classList.add('tag_nonexistent');
  2992. } else {
  2993. add_post_tag(tag, skip_common_tags_update);
  2994. a.classList.remove('tag_nonexistent');
  2995. }
  2996. return false;
  2997. };
  2998. a.innerText = tag;
  2999. return a;
  3000. };
  3001.  
  3002. const create_tag_list = function() {
  3003. const div = document.createElement('DIV');
  3004. div.className = 'tag_list';
  3005. return div;
  3006. };
  3007.  
  3008. // generate tag button list for current tags
  3009. const current_tags_flex = create_tag_list();
  3010. current_tags_flex.style.marginBottom = '3px';
  3011. for (const current_tag of get_post_tags()) {
  3012. current_tags_flex.appendChild(create_tag_button(current_tag, false));
  3013. }
  3014.  
  3015. // replace current list with new one
  3016. while (current_tags_elem.hasChildNodes())
  3017. current_tags_elem.removeChild(current_tags_elem.lastChild);
  3018. current_tags_elem.appendChild(current_tags_flex);
  3019.  
  3020. // don't rebuild the common tags list when common tags buttons are pressed
  3021. if (skip_common_tags) return;
  3022.  
  3023. // now add common tags
  3024. // common_tags_json(_idol) should be an array of objects with an optional string "name" field and an array "tags" field,
  3025. // where the "tags" array can contain strings (space separated tags), arrays containing one string (representing a group)
  3026. // or arrays of array containing one string (representing a table)
  3027. // ex. [ { "name":"common tags", "tags":[ "tag1 tag2", ["grouped_tag1 grouped_tag2"] , "tag3 tag4"] }, { "name":"uncommon tags", "tags":[ "t1 t2 t3" ]} ]
  3028. let tag_data;
  3029. try {
  3030. tag_data = JSON.parse(config[COMMON_TAGS_KEY]);
  3031. } catch (error) {
  3032. show_notice(console.error, '[addon error] "common tags" JSON syntax error', error);
  3033. return;
  3034. }
  3035.  
  3036. if (!Array.isArray(tag_data)) {
  3037. show_notice(console.error, '[addon error] "common tags" needs to be an array of objects');
  3038. return;
  3039. }
  3040.  
  3041. while (common_tags_elem.hasChildNodes())
  3042. common_tags_elem.removeChild(common_tags_elem.lastChild);
  3043.  
  3044. for (let k = 0; k < tag_data.length; k++) {
  3045. const list_flex = create_tag_list();
  3046. const list_name = tag_data[k].name;
  3047. const list_tags = tag_data[k].tags;
  3048.  
  3049. if (!Array.isArray(list_tags)) {
  3050. show_notice(console.error, '[addon error] a "common tags" object needs to have a "tags" array');
  3051. return;
  3052. }
  3053.  
  3054. const TAGS_TYPES = {
  3055. LIST: 'list', // e.g. "tag1 tag2"
  3056. GROUP: 'group', // e.g. ["tag1 tag2"]
  3057. TABLE: 'table' // e.g. [["tag1 tag2"], ["tag3 tag4"]]
  3058. };
  3059.  
  3060. const group_style = function(el) {
  3061. // red in darkmode needs more contrast
  3062. const rgb = rgb_to_array(get_original_background_color());
  3063. if (is_darkmode()) {
  3064. el.style.border = '1px solid ' + rgb_array_to_rgb(rgb_array_shift(rgb, 96));
  3065. el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, 64));
  3066. } else {
  3067. el.style.border = '1px solid ' + rgb_array_to_rgb(rgb_array_shift(rgb, -64));
  3068. el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, -32));
  3069. }
  3070. };
  3071.  
  3072. for (const list_tag of list_tags) {
  3073. const is_array = Array.isArray(list_tag);
  3074.  
  3075. // find tags_type
  3076. let tags_type;
  3077. if (is_array) {
  3078. if (list_tag.length === 0) {
  3079. show_notice(console.error, '[addon error] "common tags" "tags" array contains an empty array');
  3080. return;
  3081. }
  3082.  
  3083. // check what the array consists of
  3084. let all_arrays = true;
  3085. let no_arrays = true;
  3086. for (let i = 0; i < list_tag.length; i++) {
  3087. if (!Array.isArray(list_tag[i])) {
  3088. all_arrays = false;
  3089. } else {
  3090. no_arrays = false;
  3091. }
  3092. }
  3093.  
  3094. if (all_arrays) {
  3095. tags_type = TAGS_TYPES.TABLE;
  3096. } else if (no_arrays) {
  3097. tags_type = TAGS_TYPES.GROUP;
  3098. } else {
  3099. show_notice(console.error, '[addon error] "common tags" "tags" array contains an array which is neither a group nor a table');
  3100. return;
  3101. }
  3102. } else {
  3103. tags_type = TAGS_TYPES.LIST;
  3104. }
  3105.  
  3106. if (tags_type === TAGS_TYPES.TABLE) {
  3107. const tags_table = [];
  3108. for (let j = 0; j < list_tag.length; j++) {
  3109. if (list_tag[j].length !== 1) {
  3110. show_notice(console.error, '[addon error] "common tags" "tags" array contains a table entry with not exactly 1 tags string');
  3111. return;
  3112. }
  3113.  
  3114. tags_table.push(new Tags(list_tag[j][0]).toArray());
  3115. }
  3116.  
  3117. const table_height = tags_table.length;
  3118. let table_width = 0;
  3119. for (let row = 0; row < tags_table.length; row++)
  3120. table_width = Math.max(table_width, tags_table[row].length);
  3121.  
  3122. // div (flexbox)><table><tr><td><div (button)>
  3123. // TODO maybe replace with grid
  3124. const table = document.createElement('TABLE');
  3125. table.style.display = 'inline-block';
  3126. group_style(table);
  3127. for (let row = 0; row < table_height; row++) {
  3128. const tr = document.createElement('TR');
  3129. for (let col = 0; col < table_width; col++) {
  3130. const td = document.createElement('TD');
  3131. td.style.border = 'none';
  3132. td.style.padding = '0';
  3133. if (tags_table[row][col])
  3134. td.appendChild(create_tag_button(tags_table[row][col], true));
  3135. tr.appendChild(td);
  3136. }
  3137. table.appendChild(tr);
  3138. }
  3139.  
  3140. list_flex.appendChild(table);
  3141. } else if (tags_type === TAGS_TYPES.GROUP) {
  3142. if (list_tag.length !== 1) {
  3143. show_notice(console.error, '[addon error] "common tags" "tags" array contains a group with not exactly 1 tags string');
  3144. return;
  3145. }
  3146.  
  3147. const tags = list_tag[0].trim().split(/\s+/);
  3148.  
  3149. // <div (flexbox)><div (flexbox)><div (button)>
  3150. const group_div = document.createElement('DIV');
  3151. group_div.className = 'tag_group';
  3152. group_style(group_div);
  3153.  
  3154. for (const tag of tags)
  3155. group_div.appendChild(create_tag_button(tag, true));
  3156.  
  3157. list_flex.appendChild(group_div);
  3158. } else /* if (tags_type === tag_types.LIST) */ {
  3159. // <div (flexbox)><div (button)>
  3160. const tags = new Tags(list_tag);
  3161. for (const tag of tags) {
  3162. list_flex.appendChild(create_tag_button(tag, true));
  3163. }
  3164. }
  3165. }
  3166.  
  3167. const span = document.createElement('SPAN');
  3168. span.innerText = (list_name ? `${list_name}:` : '');
  3169. span.style.paddingTop = '2px';
  3170. if (list_name) span.style.marginLeft = '2px';
  3171.  
  3172. if (list_name && config.tag_menu_layout === 1) {
  3173. const add_top_border = function(el) {
  3174. el.style.borderTopWidth = '1px';
  3175. el.style.borderTopStyle = 'solid';
  3176. el.style.borderTopColor = shifted_backgroundColor(32);
  3177. };
  3178. add_top_border(span);
  3179. add_top_border(list_flex);
  3180. }
  3181.  
  3182. common_tags_elem.appendChild(span);
  3183. common_tags_elem.appendChild(list_flex);
  3184. }
  3185. }
  3186.  
  3187. function show_tag_menu(bool) {
  3188. document.getElementById('tag_menu').style.display = (bool ? '' : 'none');
  3189. document.getElementById('tag_menu_open').style.display = (!bool ? '' : 'none');
  3190. }
  3191.  
  3192. function add_tags_change_listener() {
  3193. const post_tags = get_post_tags_el();
  3194. if (post_tags === null) return; // not logged in
  3195.  
  3196. const delayed_update = () => {
  3197. clearTimeout(tag_update_timer);
  3198. tag_update_timer = setTimeout(update_tag_elements, 400);
  3199. };
  3200.  
  3201. post_tags.addEventListener('change', delayed_update);
  3202. post_tags.addEventListener('input', delayed_update);
  3203. }
  3204.  
  3205.  
  3206. function add_tags_submit_listener() {
  3207. document.getElementById('edit-form')?.addEventListener('submit', () => {
  3208. delete_useless_tags_tag();
  3209. });
  3210. }
  3211.  
  3212. function find_actions_list() {
  3213. let actions_ul;
  3214.  
  3215. for (const a of document.querySelectorAll('.sidebar > div > ul:not(#tag-sidebar) > li > a')) {
  3216. try {
  3217. const pathname = new URL(a.href).pathname;
  3218. if (pathname.startsWith('/posts/similar') || pathname.startsWith('/post/similar')) {
  3219. actions_ul = a.parentElement.parentElement;
  3220. } else if (pathname.startsWith('/posts/delete/') || pathname.startsWith('/post/delete/')) {
  3221. found_delete_action = true;
  3222. }
  3223. } catch (ignored) {}
  3224. }
  3225.  
  3226. return actions_ul;
  3227. }
  3228.  
  3229. function copy_translations() {
  3230. config.notes = [];
  3231.  
  3232. for (const note of unsafeWindow.Note.all) {
  3233. config.notes.push({
  3234. text: note.old.raw_body,
  3235. fullsize: note.fullsize,
  3236. });
  3237. }
  3238.  
  3239. save_setting('notes', config.notes);
  3240. show_notice(console.log, '[addon] copied notes');
  3241. }
  3242.  
  3243. function paste_translations() {
  3244. show_notice(console.log, '[addon] pasted notes');
  3245.  
  3246. // TODO check https://chan.sankakucomplex.com/wiki/show?title=about%3Anote_formatting
  3247. // TODO save all notes
  3248.  
  3249. for (const note of config.notes) {
  3250. unsafeWindow.Note.create();
  3251. const new_note = unsafeWindow.Note.all.at(-1);
  3252.  
  3253. const box = new_note.elements.box;
  3254. const image = new_note.elements.image;
  3255. const ratio = new_note.ratio();
  3256.  
  3257. // set text
  3258. new_note.elements.body.innerText = note.text;
  3259. new_note.old.raw_body = note.text;
  3260.  
  3261. // set size
  3262. const width = note.fullsize.width * ratio;
  3263. const height = note.fullsize.height * ratio;
  3264. box.style.width = width + 'px';
  3265. box.style.height = height + 'px';
  3266.  
  3267. // set position
  3268. const clipX = (x) => Math.max(5, Math.min(x, image.clientWidth - box.clientWidth - 5));
  3269. const clipY = (y) => Math.max(5, Math.min(y, image.clientHeight - box.clientHeight - 5));
  3270. const top = clipY(note.fullsize.top * ratio);
  3271. const left = clipX(note.fullsize.left * ratio);
  3272. box.style.top = top + 'px';
  3273. box.style.left = left + 'px';
  3274. }
  3275. }
  3276.  
  3277. function add_addon_actions(actions_ul) {
  3278. if (actions_ul == null) {
  3279. show_notice(console.error, '[addon error] couldn\'t find actions list! Disabled addon actions.');
  3280. return;
  3281. }
  3282.  
  3283. const separator = document.createElement('H5');
  3284. separator.innerText = 'Addon actions';
  3285. const newli = document.createElement('LI');
  3286. newli.appendChild(separator);
  3287. actions_ul.appendChild(newli);
  3288.  
  3289. const add_action = function(func, name, id) {
  3290. const a = document.createElement('A');
  3291. a.href = '#';
  3292. a.onclick = () => { func(); return false; };
  3293. a.innerText = name;
  3294.  
  3295. const li = document.createElement('LI');
  3296. li.id = id;
  3297. li.appendChild(a);
  3298. actions_ul.appendChild(li);
  3299. };
  3300.  
  3301. add_action(() => { scale_image(SCALE_MODES.FIT, true); scroll_to_image(); }, 'Fit image', 'scale-image-fit');
  3302. add_action(() => { scale_image(SCALE_MODES.HORIZONTAL, true); scroll_to_image(); }, 'Fit image (Horizontal)', 'scale-image-hor');
  3303. add_action(() => { scale_image(SCALE_MODES.VERTICAL, true); scroll_to_image(); }, 'Fit image (Vertical)', 'scale-image-ver');
  3304. add_action(() => { scale_image(SCALE_MODES.RESET, true); scroll_to_image(); }, 'Reset image size', 'reset-image');
  3305.  
  3306. if (PostPage.parent_id === null) return; // not logged in
  3307.  
  3308. add_action(() => { flag_duplicate(PostPage.post_id, ''); }, 'Flag duplicate', 'flag-duplicate');
  3309. add_action(() => { flag_duplicate(PostPage.post_id, ', visually identical'); }, 'Flag duplicate (identical)', 'flag-duplicate-identical');
  3310. add_action(() => { flag_duplicate(PostPage.post_id, ' with worse quality'); }, 'Flag duplicate (quality)', 'flag-duplicate-quality');
  3311. add_action(() => { flag_duplicate(PostPage.post_id, ' with worse resolution'); }, 'Flag duplicate (resolution)', 'flag-duplicate-resolution');
  3312.  
  3313. add_action(copy_translations, 'Copy Notes', 'copy-notes');
  3314. add_action(paste_translations, 'Paste Notes', 'paste-notes');
  3315. }
  3316.  
  3317. function add_flagger_links() {
  3318. for (const a of document.querySelectorAll('.flag-and-reason-count > a')) {
  3319. const username = a.innerText;
  3320.  
  3321. const flagged = document.createElement('A');
  3322. flagged.innerText = 'flagged';
  3323. flagged.href = get_search_url(`flagger:${username}`);
  3324.  
  3325. a.insertAdjacentText('afterend', ')');
  3326. a.insertAdjacentElement('afterend', flagged);
  3327. a.insertAdjacentText('afterend', ' (');
  3328. }
  3329. }
  3330.  
  3331. function link_to_post_tag_history() {
  3332. if (PostPage.post_id === null) return;
  3333.  
  3334. for (const a of document.querySelectorAll('#subnavbar > li > a')) {
  3335. try {
  3336. const url = new URL(a.href);
  3337.  
  3338. if (url.pathname.startsWith('/post_tag_history/index')) {
  3339. url.search = 'post_id=' + PostPage.post_id;
  3340. a.href = url;
  3341. }
  3342.  
  3343. } catch (ignored) {}
  3344. }
  3345. }
  3346.  
  3347. function move_stats_to_edit_form() {
  3348. try {
  3349.  
  3350. // form display: flex
  3351. // stats insertafter table
  3352. // stats white-space: nowrap
  3353. // #edit-form width: max-content
  3354. // margin-left: 8px;
  3355.  
  3356. const stats = document.getElementById('stats');
  3357.  
  3358. const edit_form = document.getElementById('edit-form');
  3359. if (edit_form === null) return;
  3360.  
  3361. const table = edit_form.querySelector('table');
  3362.  
  3363. edit_form.style.display = 'flex'; // add to the right
  3364.  
  3365. insert_node_after(stats, table);
  3366. stats.style.whiteSpace = 'nowrap';
  3367. edit_form.style.width = 'max-content';
  3368. stats.style.marginLeft = '8px';
  3369. } catch (error) {
  3370. console.error('[addon error] move_stats_to_edit_form failed with', error);
  3371. }
  3372. }
  3373.  
  3374. function add_tag_buttons(form_id) {
  3375. const edit_form = document.getElementById(form_id);
  3376. if (edit_form === null) return; // not logged in
  3377.  
  3378. const tags_div = document.getElementById('post-tags-container');
  3379. if (tags_div) {
  3380. // remove block display, let's div fit the text area
  3381. tags_div.style.removeProperty('display');
  3382. }
  3383.  
  3384. const parent_el = document.getElementById('post_parent_id');
  3385.  
  3386. {
  3387. const el = document.createElement('BUTTON');
  3388. el.id = 'clear_parent_id_button';
  3389. el.innerText = 'Clear';
  3390. el.onclick = () => { parent_el.value = ''; return false; };
  3391. parent_el?.parentNode?.appendChild(el);
  3392. }
  3393.  
  3394. {
  3395. const el = document.createElement('BUTTON');
  3396. el.id = 'reset_parent_id_button';
  3397. el.innerText = 'Reset';
  3398. el.onclick = () => { reset_parent_id(); return false; };
  3399. parent_el?.parentNode?.appendChild(el);
  3400. }
  3401.  
  3402. const tag_button_place = document.querySelector('#edit-post-submit')?.parentElement;
  3403. if (!tag_button_place) {
  3404. show_notice(console.error, '[addon error] couldn\'t find tags submit button');
  3405. return;
  3406. }
  3407.  
  3408. if (tag_button_place.align === 'right') {
  3409. tag_button_place.align = 'left';
  3410. }
  3411.  
  3412. {
  3413. const el = document.createElement('BUTTON');
  3414. el.id = 'tag_reset_button';
  3415. el.innerText = 'Reset';
  3416. el.onclick = () => { reset_tags(); return false; };
  3417. tag_button_place?.appendChild(el);
  3418. }
  3419.  
  3420. const append_tag_button = (id, tag) => {
  3421. const el = document.createElement('BUTTON');
  3422. el.id = id;
  3423. el.className = 'SA-tag-button';
  3424. el.dataset['tag'] = tag;
  3425. el.onclick = () => { toggle_post_tag(tag); return false; };
  3426. tag_button_place?.appendChild(el);
  3427. };
  3428.  
  3429. append_tag_button('tag_dup_button', 'duplicate');
  3430. append_tag_button('tag_var_button', 'legitimate_variation');
  3431. append_tag_button('tag_rev_button', 'revision');
  3432. append_tag_button('tag_has_rev_button', 'has_revised_version');
  3433. append_tag_button('tag_pot_button', 'potential_duplicate');
  3434. }
  3435.  
  3436. function update_tag_buttons() {
  3437. const taglist = get_post_tags_el();
  3438. if (taglist === null)
  3439. return;
  3440.  
  3441. const tags = get_post_tags();
  3442.  
  3443. for (const button of document.querySelectorAll('.SA-tag-button')) {
  3444. const tag = button.dataset['tag'];
  3445.  
  3446. if (tag === 'potential_duplicate') {
  3447. if (tags.has(tag)) {
  3448. button.disabled = false;
  3449. button.style.removeProperty('cursor');
  3450. } else {
  3451. button.disabled = true;
  3452. button.style.cursor = 'not-allowed';
  3453. }
  3454. }
  3455.  
  3456. button.innerText = (tags?.has(tag) ? '-' : '+') + tag;
  3457. }
  3458. }
  3459.  
  3460. function reset_parent_id() {
  3461. document.getElementById('post_parent_id').value = PostPage.parent_id;
  3462. }
  3463.  
  3464. function get_post_old_tags_el() {
  3465. return document.querySelector('#post_old_tags, #SA-post_old_tags');
  3466. }
  3467.  
  3468. function get_post_tags_el() {
  3469. return document.querySelector('#post_tags, #SA-post_tags');
  3470. }
  3471.  
  3472. function get_post_old_tags() {
  3473. return new Tags(get_post_old_tags_el()?.value);
  3474. }
  3475.  
  3476. function get_post_tags() {
  3477. return new Tags(get_post_tags_el()?.value);
  3478. }
  3479.  
  3480. function toggle_post_tag(tag, skip_common_tags_update = false) {
  3481. if (get_post_tags().has(tag)) {
  3482. remove_post_tag(tag, skip_common_tags_update);
  3483. } else {
  3484. add_post_tag(tag, skip_common_tags_update);
  3485. }
  3486. }
  3487.  
  3488. function add_post_tag(tag, skip_common_tags_update = false) {
  3489. const tags_el = get_post_tags_el();
  3490. const tags = get_post_tags();
  3491.  
  3492. if ((tag === 'duplicate' && tags.has('legitimate_variation')) || (tag === 'legitimate_variation' && tags.has('duplicate'))) {
  3493. show_notice(console.warn, '[addon] cannot tag as duplicate and legitimate_variation at the same time.');
  3494. return;
  3495. }
  3496.  
  3497. tags.add(tag);
  3498. tags_el.value = tags.toString() + ' ';
  3499.  
  3500. update_tag_elements(skip_common_tags_update);
  3501. }
  3502.  
  3503. function remove_post_tag(tag, skip_common_tags_update = false) {
  3504. const tags = get_post_tags();
  3505.  
  3506. tags.remove(tag);
  3507. get_post_tags_el().value = tags.toString() + ' ';
  3508.  
  3509. update_tag_elements(skip_common_tags_update);
  3510. }
  3511.  
  3512. function delete_useless_tags_tag() {
  3513. if (config.editform_deleteuselesstags) remove_post_tag('useless_tags');
  3514. }
  3515.  
  3516. function reset_tags() {
  3517. get_post_tags_el().value = get_post_old_tags_el().value;
  3518. update_tag_elements();
  3519. }
  3520.  
  3521. function update_tag_elements(skip_common_tags = false) {
  3522. update_tag_buttons();
  3523.  
  3524. const tag_menu = document.getElementById('tag_menu');
  3525. if (tag_menu !== null && tag_menu.style.display !== 'none') {
  3526. update_tag_menu(skip_common_tags);
  3527. }
  3528. }
  3529.  
  3530.  
  3531.  
  3532. // flag option with default text
  3533. function flag_duplicate(post_id, reason_suffix) {
  3534. if (post_id === null) {
  3535. show_notice(console.error, '[addon] no post id, report to author!');
  3536. return;
  3537. }
  3538. if (PostPage.parent_id === null) {
  3539. show_notice(console.warn, '[addon] parent id not found, not logged in?');
  3540. return;
  3541. }
  3542.  
  3543. const current_parent_id = document.getElementById('post_parent_id')?.value;
  3544. if (current_parent_id !== PostPage.parent_id) {
  3545. show_notice(console.warn, '[addon] parent id was changed but not saved!');
  3546. return;
  3547. }
  3548.  
  3549. if (!current_parent_id || current_parent_id.length === 0) {
  3550. show_notice(console.warn, '[addon] no parent id set!');
  3551. return;
  3552. }
  3553.  
  3554. const tags = get_post_tags();
  3555. const old_tags = get_post_old_tags();
  3556. if (tags.has('duplicate') && !old_tags.has('duplicate')) {
  3557. show_notice(console.warn, '[addon] duplicate tag set but not saved!');
  3558. return;
  3559. }
  3560. if (!old_tags.has('duplicate')) {
  3561. show_notice(console.warn, '[addon] not tagged as duplicate!');
  3562. return;
  3563. }
  3564.  
  3565. if (old_tags.has('legitimate_variation') || old_tags.has('revision'))
  3566. if (!window.confirm('Post is tagged as a legitimate_variation or revision, it may not be a duplicate!\n\nFlag it anyway?'))
  3567. return;
  3568.  
  3569. const reason = window.prompt('Why should this post be reconsidered for moderation?', `duplicate of ${PostPage.parent_id}${reason_suffix}`);
  3570. if (reason === null)
  3571. return;
  3572.  
  3573. const flag_reason = document.getElementById('other_reason');
  3574. const flag_confirm = document.getElementById('confirm');
  3575. const flag_form = flag_reason?.parentElement;
  3576. if (flag_reason === null || flag_confirm === null) {
  3577. show_notice(console.error, '[addon error] couldn\'t find flag form');
  3578. return;
  3579. }
  3580.  
  3581. flag_reason.value = reason;
  3582. flag_confirm.checked = true;
  3583.  
  3584. (async function() {
  3585. try {
  3586. const response = await fetch(new URL(flag_form.action, document.location.origin), {
  3587. method: 'POST',
  3588. body: new FormData(flag_form),
  3589. });
  3590.  
  3591. if (!response.ok) {
  3592. show_notice(console.error, `[addon error] non-OK status code ${response.status}`);
  3593. return;
  3594. }
  3595.  
  3596. show_notice(console.log, 'Post was resent to moderation queue');
  3597. } catch (error) {
  3598. show_notice(console.error, '[addon error] error flagging post!', error);
  3599. }
  3600. })();
  3601. }
  3602.  
  3603. // writes to image_data once finished
  3604. async function read_image_data() {
  3605. const data = {
  3606. img_elem: null, // <img>, <video> or <object> (in case of flash)
  3607. emb_elem: null, non_img_div: null, // flash is <object><embed>, we need the <div> it's in as well
  3608. is_flash: false,
  3609. width: null,
  3610. height: null,
  3611. aspect_ratio: null,
  3612. };
  3613.  
  3614. // image or video
  3615. const img = document.getElementById('image');
  3616. if (img !== null) {
  3617. data.img_elem = img;
  3618.  
  3619. // the href of this element is removed when the original image is loaded
  3620. const sample_link = document.querySelector('a#image-link.sample');
  3621. const is_sample = sample_link !== null && sample_link.hasAttribute('href');
  3622.  
  3623. let res = null;
  3624. if (is_sample) {
  3625. const lowres = document.getElementById('lowres');
  3626. if (lowres !== null) {
  3627. res = lowres.innerText.split('x'); // parse "<width>x<height>"
  3628. }
  3629. } else {
  3630. const highres = document.getElementById('highres');
  3631. if (highres !== null) {
  3632. res = highres.innerText.split(' ')[0].split('x'); // parse "<width>x<height> (<file size>)"
  3633. }
  3634.  
  3635. if (res === null) {
  3636. if (img.hasAttribute('orig_width') && img.hasAttribute('orig_height')) {
  3637. res = [img.getAttribute('orig_width'), img.getAttribute('orig_height')];
  3638. }
  3639. }
  3640. }
  3641.  
  3642. if (res === null) {
  3643. console.log('[addon] Couldn\'t read resolution from details section, waiting for image size...');
  3644.  
  3645. // last resort: try to read natural size
  3646. // when loading the original image, this can read the old preview size instead, which shouldn't be a huge deal since
  3647. // this happens after image scrolling (see is_done_scrolling()) and the aspect ratio is approximately correct
  3648. // TODO: this will however break the manual image scrolling
  3649.  
  3650. // TODO: should abort when content failed loading
  3651. while ((res = get_resolution(img)) === null)
  3652. await sleep(20);
  3653. }
  3654.  
  3655. data.width = Number(res[0]);
  3656. data.height = Number(res[1]);
  3657. console.log('[addon] Read image or video resolution ', data.width, 'x', data.height);
  3658.  
  3659. data.aspect_ratio = data.width / data.height;
  3660. image_data = data;
  3661. return;
  3662. }
  3663.  
  3664. // flash or unknown
  3665. const non_img = document.getElementById('non-image-content');
  3666. if (non_img !== null) {
  3667. data.non_img_div = non_img;
  3668.  
  3669. const objs = non_img.getElementsByTagName('OBJECT');
  3670. const embs = non_img.getElementsByTagName('EMBED');
  3671. data.is_flash = (objs.length === 1 && embs.length === 1); // <object><embed>
  3672.  
  3673. if (!data.is_flash) {
  3674. show_notice(console.error, '[addon error] unknown post content! Can\'t read width/height.');
  3675. return;
  3676. }
  3677.  
  3678. data.img_elem = objs[0];
  3679. data.emb_elem = embs[0];
  3680. // <object> contains width/height in both Firefox and Chrome
  3681. data.width = data.img_elem.width;
  3682. data.height = data.img_elem.height;
  3683. console.log('[addon] Read resolution ', data.width, 'x', data.height);
  3684.  
  3685. data.aspect_ratio = data.width / data.height;
  3686. image_data = data;
  3687. }
  3688. }
  3689.  
  3690. const SCALE_MODES = { RESET: -1, FIT: 0, HORIZONTAL: 1, VERTICAL: 2 };
  3691.  
  3692. // stretch image/video/flash, requires data from read_image_data()
  3693. function scale_image(mode, always_scale) {
  3694. if (image_data === null) return; // read_image_data() failed
  3695.  
  3696. if (!always_scale && (!config.scale_flash && image_data.is_flash))
  3697. return;
  3698.  
  3699. // We can't use transform scale because it doesn't change the DOM size (so the image could be covered up by other elements)
  3700. // We also can't use style.width/height because translation notes rely on .width/.height
  3701. const set_dimensions = (obj, dim) => {
  3702. obj.width = dim.width;
  3703. obj.height = dim.height;
  3704. };
  3705.  
  3706. // reset image size
  3707. if (mode === SCALE_MODES.RESET) {
  3708. if (!image_data.is_flash) {
  3709. set_dimensions(image_data.img_elem, image_data);
  3710. image_data.img_elem.classList.add('fit-width');
  3711. image_data.img_elem.classList.add('fit-height');
  3712. adjust_notes();
  3713. } else {
  3714. set_dimensions(image_data.img_elem, image_data);
  3715. set_dimensions(image_data.emb_elem, image_data);
  3716. }
  3717.  
  3718. return;
  3719. }
  3720.  
  3721. const left_side = image_data.img_elem.getBoundingClientRect().left + window.scrollX;
  3722. const target_w = Math.max(window.innerWidth - Math.ceil(left_side) - get_scrollbar_width(), 1);
  3723. const target_h = Math.max(window.innerHeight, 1);
  3724. const target_aspect_ratio = target_w / target_h;
  3725.  
  3726. if (mode === SCALE_MODES.FIT)
  3727. mode = (image_data.aspect_ratio > target_aspect_ratio ? SCALE_MODES.HORIZONTAL : SCALE_MODES.VERTICAL);
  3728.  
  3729. const scaled = {};
  3730. if (mode === SCALE_MODES.HORIZONTAL) {
  3731. scaled.width = Math.floor(target_w);
  3732. scaled.height = Math.floor(target_w / image_data.aspect_ratio);
  3733. } else if (mode === SCALE_MODES.VERTICAL) {
  3734. scaled.width = Math.floor(target_h * image_data.aspect_ratio);
  3735. scaled.height = Math.floor(target_h);
  3736. }
  3737.  
  3738. if (!always_scale && (config.scale_only_downscale && (scaled.width > image_data.width || scaled.height > image_data.height)))
  3739. return;
  3740.  
  3741. if (!image_data.is_flash) {
  3742. set_dimensions(image_data.img_elem, scaled);
  3743. image_data.img_elem.classList.remove('fit-width');
  3744. image_data.img_elem.classList.remove('fit-height');
  3745. adjust_notes();
  3746. } else {
  3747. set_dimensions(image_data.img_elem, scaled);
  3748. set_dimensions(image_data.emb_elem, scaled);
  3749. }
  3750. }
  3751.  
  3752. function adjust_notes() {
  3753. for (const note of unsafeWindow.Note?.all ?? []) {
  3754. note.adjustScale(); // this relies on the image's .width and .height
  3755. }
  3756. }
  3757.  
  3758. function scale_on_resize_helper() {
  3759. clearTimeout(resize_timer);
  3760. resize_timer = setTimeout(() => {
  3761. if (config.scale_on_resize) scale_image(config.scale_mode, false);
  3762. }, 100);
  3763. }
  3764.  
  3765. function add_scale_on_resize_listener() {
  3766. window.addEventListener('resize', scale_on_resize_helper);
  3767. }
  3768.  
  3769. function remove_scale_on_resize_listener() {
  3770. window.removeEventListener('resize', scale_on_resize_helper);
  3771. }
  3772.  
  3773. function scroll_to_image() {
  3774. window.requestAnimationFrame(() => {
  3775. if (image_data === null) return;
  3776. const img_rect = (image_data.is_flash ? image_data.non_img_div : image_data.img_elem).getBoundingClientRect();
  3777. const absolute_img_top = Math.round(img_rect.top) + window.scrollY;
  3778. if (config.scroll_to_image_center && img_rect.height !== 0) { // TODO height may or may not be 0, needs more testing
  3779. const top_of_centered_rect = absolute_img_top - (window.innerHeight - img_rect.height) / 2;
  3780. window.scrollTo(0, top_of_centered_rect);
  3781. } else {
  3782. window.scrollTo(0, absolute_img_top);
  3783. }
  3784. done_scrolling = true;
  3785. });
  3786. }
  3787.  
  3788. // when resize notice is hidden (e.g. original image is loaded), scroll to make up the difference
  3789. function add_resize_notice_listener() {
  3790. const resized_notice = document.getElementById('resized_notice');
  3791. if (image_data === null || resized_notice === null) return;
  3792.  
  3793. const notice_y_diff = image_data.img_elem.getBoundingClientRect().top - resized_notice.getBoundingClientRect().top;
  3794.  
  3795. const observer = new MutationObserver(() => {
  3796. observer.disconnect();
  3797. window.scrollBy(0, -notice_y_diff);
  3798. });
  3799. observer.observe(resized_notice, { attributeFilter: ['style'] });
  3800. }
  3801.  
  3802. function add_highres_listener() {
  3803. const img = document.getElementById('image');
  3804. if (img === null) return;
  3805.  
  3806. const observer = new MutationObserver(() => {
  3807. // image is cleared before highres image is loaded
  3808. if (img.src === 'about:blank') return;
  3809. observer.disconnect();
  3810.  
  3811. // re-read image size in case get_resolution() had to be used
  3812. read_image_data().then(() => {
  3813. if (config.scale_image) scale_image(config.scale_mode, false);
  3814. });
  3815. });
  3816.  
  3817. observer.observe(img, { attributeFilter: ['src'] });
  3818. }
  3819.  
  3820. async function load_highres() {
  3821. if (!config.load_highres) return;
  3822.  
  3823. if (config.highres_limit > 0) {
  3824. const highres = document.getElementById('highres');
  3825. if (highres !== null) {
  3826. let size = highres.title; // e.g. "1,738,253 bytes"
  3827. size = size.replaceAll(',', '');
  3828. size = parseInt(size, 10);
  3829.  
  3830. if (size > config.highres_limit) {
  3831. return;
  3832. }
  3833. }
  3834. }
  3835.  
  3836. while (!is_done_scrolling() || unsafeWindow.Post?.highres === undefined)
  3837. await sleep(20);
  3838.  
  3839. // mimic sitescript
  3840. unsafeWindow.jQuery('a#image-link.sample').unbind('click').removeAttr('href');
  3841. unsafeWindow.Post.highres();
  3842. }
  3843.  
  3844. function redirect_v_to_s_server() {
  3845. const img = document.getElementById('image');
  3846. if (img === null) return;
  3847.  
  3848. const url = new URL(img.src);
  3849. if (url.host === 'v.sankakucomplex.com') {
  3850. url.host = 's.sankakucomplex.com';
  3851. img.src = url;
  3852. }
  3853. }
  3854.  
  3855. function add_postpage_hotkeys() {
  3856. document.addEventListener('keydown', (e) => {
  3857. if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
  3858.  
  3859. for (const hotkey of config.postpage_hotkeys) {
  3860. hotkey.call(e);
  3861. }
  3862. }, true);
  3863. }
  3864.  
  3865. // sitefix: fix pixiv source links under 'Details'
  3866. // issue: source links of the form https://www.pixiv.net/artworks/<id> turn into
  3867. // https://www.pixiv.net/artworks/https://www.pixiv.net/artworks/<id>
  3868. // doesn't happen for https://www.pixiv.net/en/artworks/<id>
  3869. // or the old format https://www.pixiv.net/member_illust.php?mode=medium&illust_id=<id>
  3870. function fix_source_link() {
  3871. const stats = document.getElementById('stats');
  3872. if (stats === null) return;
  3873.  
  3874. for (const link of stats.getElementsByTagName('A')) {
  3875. if (!link.href) continue;
  3876. if (link.href.startsWith('https://www.pixiv.net/artworks/')) {
  3877. const id = link.href.substring('https://www.pixiv.net/artworks/'.length);
  3878. try {
  3879. new URL(id); // throws if not a valid URL
  3880. link.href = id;
  3881. } catch (ignore) { }
  3882. } else {
  3883. const match = /https:\/\/pictures.hentai-foundry.com\/[^/]\/([^/]+)\/(\d+)\//.exec(link.href);
  3884. if (match) {
  3885. const [,user, post_id] = match;
  3886. link.href = `https://www.hentai-foundry.com/pictures/user/${user}/${post_id}`;
  3887. }
  3888. }
  3889. }
  3890. }
  3891.  
  3892. /***********************/
  3893. /* wiki page functions */
  3894. /***********************/
  3895.  
  3896. function add_wiki_template() {
  3897. if (config.wiki_template.length === 0) return;
  3898.  
  3899. const wiki_form = document.getElementById('wiki-form');
  3900. const wiki_body = document.getElementById('wiki_page_body');
  3901.  
  3902. if (wiki_form === null || wiki_body === null) {
  3903. show_notice(console.error, '[addon error] couldn\'t find "wiki-form" or "wiki_page_body", wiki template disabled');
  3904. return;
  3905. }
  3906.  
  3907. wiki_form.style.display = 'flex'; // add template to the right
  3908.  
  3909. const div = document.createElement('DIV');
  3910. div.style.marginLeft = '1em';
  3911.  
  3912. const template_label = document.createElement('LABEL');
  3913. template_label.innerText = 'Wiki Template';
  3914. template_label.style.cursor = 'help';
  3915. template_label.style.textDecoration = 'underline dashed';
  3916. template_label.title = 'Selected text can be appended to the page body. Clicking or using arrow keys selects a whole line, pressing \'c\' or the button below copies the selection over';
  3917.  
  3918. const template_text = document.createElement('TEXTAREA');
  3919. template_text.id = 'wiki_template_text';
  3920. template_text.cols = wiki_body.cols;
  3921. template_text.rows = wiki_body.rows;
  3922. template_text.style.width = '33em';
  3923. template_text.style.marginTop = '0';
  3924. template_text.value = config.wiki_template;
  3925.  
  3926. const insert_template_selection = () => {
  3927. const text = template_text.value;
  3928. const a = template_text.selectionStart;
  3929. const b = template_text.selectionEnd;
  3930.  
  3931. const selection = text.substring(a, b);
  3932. const add_newline = wiki_body.value && !wiki_body.value.endsWith('\n');
  3933. wiki_body.value += (add_newline ? '\n' : '') + selection;
  3934. };
  3935.  
  3936. const extend_selection = () => {
  3937. // extend empty selection to newlines (or text start/end)
  3938. const text = template_text.value;
  3939. let a = template_text.selectionStart;
  3940. let b = template_text.selectionEnd;
  3941.  
  3942. if (a === b) {
  3943. const ext_a = text.lastIndexOf('\n', a - 1);
  3944. a = (ext_a !== -1 ? ext_a + 1 : 0);
  3945.  
  3946. if (text.charAt(b) !== '\n') {
  3947. const ext_b = text.indexOf('\n', b + 1);
  3948. b = (ext_b !== -1 ? ext_b - 1 : text.length - 1) + 1;
  3949. }
  3950.  
  3951. template_text.setSelectionRange(a, b);
  3952. }
  3953. };
  3954.  
  3955. template_text.readOnly = true; // hides the caret and there's no easy workaround
  3956. template_text.addEventListener('click', extend_selection);
  3957. template_text.addEventListener('keyup', extend_selection);
  3958. template_text.addEventListener('keydown', (e) => {
  3959. if (e.ctrlKey || e.altKey || e.shiftKey) return;
  3960. if (e.key === 'c') insert_template_selection();
  3961. });
  3962.  
  3963. const btn = document.createElement('BUTTON');
  3964. btn.innerText = 'Copy selection over';
  3965. btn.style.fontWeight = 'bold';
  3966. btn.style.padding = '0.2em 2em';
  3967. btn.style.margin = '0.1em';
  3968. btn.onclick = () => { insert_template_selection(); template_text.focus(); return false; };
  3969.  
  3970. div.appendChild(template_label);
  3971. div.appendChild(document.createElement('BR'));
  3972. div.appendChild(template_text);
  3973. div.appendChild(document.createElement('BR'));
  3974. div.appendChild(btn);
  3975. wiki_form.appendChild(div);
  3976.  
  3977. div.style.marginTop = (wiki_body.getBoundingClientRect().top - wiki_form.getBoundingClientRect().top - template_label.getBoundingClientRect().height - 1) + 'px';
  3978. }
  3979.  
  3980. function add_status_post_links() {
  3981. const random_posts = [...document.querySelectorAll('.highlightable a')].find(a => a.href.endsWith('%20order%3Arandom'));
  3982. if (!random_posts) return;
  3983. const user_posts = random_posts.parentElement.children[0].href;
  3984. const username = new URL(user_posts).searchParams.get('tags').substring('user:'.length);
  3985.  
  3986. const status_post_link = (name, search) => {
  3987. const a = document.createElement('A');
  3988. a.href = get_search_url(search);
  3989. a.innerText = name;
  3990. return a;
  3991. };
  3992.  
  3993. let dateSearch = '';
  3994. if (get_username() !== username) { // not your own profile
  3995. // Thanks to Evaera
  3996. // add leeway of 3 days to tag count searches (should be 2 days but date search goes by server time, which can differ from local UTC by up to 14 hours)
  3997. const threeDaysAgo = new Date();
  3998. threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
  3999. const dd = String(threeDaysAgo.getUTCDate()).padStart(2, '0');
  4000. const mm = String(threeDaysAgo.getUTCMonth() + 1).padStart(2, '0');
  4001. const yyyy = threeDaysAgo.getUTCFullYear();
  4002. dateSearch = ` date:<${yyyy}-${mm}-${dd}`;
  4003. }
  4004.  
  4005. insert_node_after(status_post_link('approver', `approver:${username}`), random_posts);
  4006. insert_node_after(document.createTextNode(', '), random_posts);
  4007. insert_node_after(status_post_link('flagger', `flagger:${username}`), random_posts);
  4008. insert_node_after(document.createTextNode(', '), random_posts);
  4009. insert_node_after(status_post_link('artist_tag_count:0', `user:${username} artist_tag_count:0${dateSearch}`), random_posts);
  4010. insert_node_after(document.createTextNode(', '), random_posts);
  4011. insert_node_after(status_post_link('general_tag_count:<13', `user:${username} general_tag_count:<13${dateSearch}`), random_posts);
  4012. insert_node_after(document.createTextNode(', '), random_posts);
  4013. insert_node_after(status_post_link('tagme', `user:${username} tagme${dateSearch}`), random_posts);
  4014. insert_node_after(document.createTextNode(', '), random_posts);
  4015. insert_node_after(status_post_link('flagged', `user:${username} status:flagged`), random_posts);
  4016. insert_node_after(document.createTextNode(', '), random_posts);
  4017. insert_node_after(status_post_link('unapproved', `user:${username} status:pending`), random_posts);
  4018. insert_node_after(document.createTextNode(', '), random_posts);
  4019. }
  4020.  
  4021. function add_record_template() {
  4022. const record_body = document.getElementById('user_record_body');
  4023. const record_score = document.getElementById('user_record_score');
  4024.  
  4025. if (record_body === null || record_score === null) {
  4026. show_notice(console.error, 'couldn\'t find record elements, disabled template');
  4027. return;
  4028. }
  4029.  
  4030. const label = document.createElement('SPAN');
  4031. label.innerText = 'Template:';
  4032. label.style.marginLeft = '0.5em';
  4033. label.style.marginRight = '0.5em';
  4034. insert_node_after(label, record_score);
  4035.  
  4036. // the JSON is supposed to be an array of 2-or-3-entry-arrays, which converts to Map
  4037. const raw_templates = JSON.parse(config.record_template);
  4038.  
  4039. const templates = new Map();
  4040. for (const [title, content, raw_score] of raw_templates) {
  4041. let score = null;
  4042. if (typeof raw_score !== 'undefined') {
  4043. score = raw_score.toLowerCase();
  4044.  
  4045. if (score === 'neutral') score = 0;
  4046. else if (score === 'positive') score = 1;
  4047. else if (score === 'negative') score = -1;
  4048. else score = null;
  4049. }
  4050.  
  4051. if (score === null) {
  4052. show_notice(console.error, '[addon error] record template has invalid score, see console for details', [title, content, raw_score]);
  4053. }
  4054.  
  4055. templates.set(title, { content, score });
  4056. }
  4057.  
  4058. const apply_template = (template) => {
  4059. record_body.value = template.content;
  4060. if (template.score !== null) record_score.value = template.score;
  4061. };
  4062.  
  4063. const dropdown = create_template_dropdown(templates, apply_template);
  4064. dropdown.id = 'template_dropdown';
  4065. insert_node_after(dropdown, label);
  4066. }
  4067.  
  4068. function add_tagscript_presets() {
  4069. if (!IndexPage.has_tag_scripts) return;
  4070.  
  4071. const mode_menu = document.getElementById('mode-menu');
  4072. if (mode_menu === null) {
  4073. show_notice(console.error, '[addon error] couldn\'t find "Mode" menu, disabled tagscript presets');
  4074. return;
  4075. }
  4076.  
  4077. // the JSON is supposed to be an array of 2-entry-arrays, which converts to Map
  4078. const presets = new Map(JSON.parse(config.tagscript_presets));
  4079.  
  4080. if (presets.size === 0)
  4081. return;
  4082.  
  4083. mode_menu.appendChild(document.createElement('P'));
  4084.  
  4085. const label = document.createElement('H5');
  4086. label.innerText = 'Tag Script Presets';
  4087. mode_menu.appendChild(label);
  4088.  
  4089. const set_tag_script = (script) => {
  4090. set_cookie('tag-script', script);
  4091. select_mode('apply-tag-script');
  4092. };
  4093.  
  4094. const dropdown = create_template_dropdown(presets, set_tag_script);
  4095. dropdown.id = 'tagscript_presets_dropdown';
  4096. mode_menu.appendChild(dropdown);
  4097. }
  4098.  
  4099. function create_template_dropdown(templates, change_event) {
  4100. const dropdown = document.createElement('SELECT');
  4101.  
  4102. const empty_option = document.createElement('OPTION');
  4103. empty_option.disabled = true;
  4104. empty_option.selected = true;
  4105. empty_option.style.display = 'none';
  4106. dropdown.appendChild(empty_option);
  4107.  
  4108. for (const [title, value] of templates.entries()) {
  4109. const option = document.createElement('OPTION');
  4110. option.innerText = title;
  4111.  
  4112. // somewhat redundant to the change event but allows to "reset" the text to an already selected template
  4113. option.addEventListener('click', (e) => {
  4114. change_event(value);
  4115. e.preventDefault();
  4116. });
  4117.  
  4118. dropdown.appendChild(option);
  4119. }
  4120.  
  4121. dropdown.addEventListener('change', (e) => {
  4122. change_event(templates.get(dropdown.value));
  4123. e.preventDefault();
  4124. });
  4125.  
  4126. return dropdown;
  4127. }
  4128.  
  4129. function add_tag_edit_gear() { // add a "⚙" link to the tag edit page
  4130. try {
  4131. const h2 = document.querySelector('.title');
  4132.  
  4133. if (WikiPage.tag === 'recent_changes') return;
  4134.  
  4135. const a = document.createElement('A');
  4136. const url = new URL(`${get_lang_path()}/tag_histories`, document.location.origin);
  4137. url.searchParams.set('tag_name', WikiPage.tag);
  4138. a.href = url.href;
  4139. a.innerText = '⚙';
  4140. a.title = 'Tag History (with edit link)';
  4141.  
  4142. h2.appendChild(a);
  4143. } catch (error) {
  4144. show_notice(console.error, '[addon error] couldn\'t add "⚙" tag page link, check console', error);
  4145. }
  4146. }
  4147.  
  4148. function add_tag_history_link() { // add a "History »" link to the tag history page
  4149. try {
  4150. const info = document.querySelector('.tag-information');
  4151. const related = document.querySelector('.related-tags');
  4152.  
  4153. // use the hidden form field because url can either be e.g. /tags/edit?name=high_resolution or /tags/edit/464292
  4154. const tag = document.getElementById('tag_name').value;
  4155.  
  4156. const div = document.createElement('DIV');
  4157. const h4 = document.createElement('H4');
  4158. const a = document.createElement('A');
  4159. a.href = '/tag_histories?tag_name=' + tag;
  4160. a.innerText = 'History »';
  4161.  
  4162. div.appendChild(h4);
  4163. h4.appendChild(a);
  4164.  
  4165. if (related !== null) {
  4166. related.insertAdjacentElement('afterend', div);
  4167. } else {
  4168. info.prepend(div);
  4169. }
  4170. } catch (error) {
  4171. show_notice(console.error, '[addon error] couldn\'t add tag history page link, check console', error);
  4172. }
  4173. }
  4174.  
  4175. function deletion_sanity_checks() { // for duplicates
  4176. const thumbs = [...document.querySelectorAll('#content > .deleting-post .thumb')];
  4177. if (thumbs.length !== 2)
  4178. return; // no parent
  4179.  
  4180. const WARNING_TEXT = '<b style="color: crimson">Warning: </b>';
  4181. const CHECKED_TAGS = ['upscaled', 'legitimate_variation', 'revision', 'third-party_edit', 'decensored', 'potential_upscale', 'md5_mismatch', 'resolution_mismatch'];
  4182. const CHECKED_COMBINATIONS = [['censored', 'uncensored']];
  4183.  
  4184. // remove old warnings before re-evaluating
  4185. document.querySelectorAll('.SA-warning').forEach((el) => el.remove());
  4186.  
  4187. const posts = thumbs.map(get_post_from_thumb);
  4188.  
  4189. const warn_tags = posts.map(post => post.tags.filter(tag => CHECKED_TAGS.includes(tag)));
  4190.  
  4191. for (const [tag1, tag2] of CHECKED_COMBINATIONS) {
  4192. for (let i = 0; i < 2; i++) {
  4193. if (posts[i].tags.includes(tag1) && posts[1 - i].tags.includes(tag2)) {
  4194. warn_tags[i].push(tag1);
  4195. warn_tags[1 - i].push(tag2);
  4196. }
  4197. }
  4198. }
  4199.  
  4200. const convert_to_margin = (thumb) => {
  4201. const a = thumb.querySelector('a');
  4202. const y_diff = a.getBoundingClientRect().top - thumb.getBoundingClientRect().top;
  4203.  
  4204. // the thumbnail image is centered in a grid (see adjust_css()), which will move it up when something is added below it
  4205. // to fix this we replace the vertical centering with a margin (and allow the thumbnail to scale vertically too)
  4206.  
  4207. // fix thumbnail in place vertically
  4208. a.style.marginTop = `${y_diff}px`;
  4209. thumb.style.alignContent = 'start';
  4210. // make thumbnail scale vertically
  4211. thumb.style.minHeight = window.getComputedStyle(thumb).height;
  4212. thumb.style.height = 'auto';
  4213. };
  4214.  
  4215. thumbs.forEach(convert_to_margin);
  4216.  
  4217. const add_tags_below_thumb = (thumb, tags, missing) => {
  4218. for (const tag of tags) {
  4219. const span = document.createElement('SPAN');
  4220. span.className = 'SA-warning';
  4221. span.style.color = 'crimson';
  4222. span.style.fontWeight = 'bold';
  4223. if (missing) span.style.textDecoration = 'line-through';
  4224. span.innerText = tag;
  4225.  
  4226. thumb.appendChild(span);
  4227. }
  4228. };
  4229.  
  4230. for (let i = 0; i < thumbs.length; i++) {
  4231. add_tags_below_thumb(thumbs[i], warn_tags[i]);
  4232. }
  4233.  
  4234. if (!posts[0].tags.includes('duplicate')) {
  4235. add_tags_below_thumb(thumbs[0], ['duplicate'], true);
  4236. }
  4237.  
  4238. const integer_multiple = (a, b) => {
  4239. if (a < b) return integer_multiple(b, a);
  4240. if (a > b && a % b === 0) {
  4241. return a / b;
  4242. }
  4243. return NaN;
  4244. };
  4245.  
  4246. const widths = [];
  4247. const heights = [];
  4248.  
  4249. // read resolutions
  4250. const res = document.querySelector('#content > .deleting-post > ul > li:nth-child(2)');
  4251. for (const b of res.getElementsByTagName('B')) {
  4252. const match = /([\d]+)x([\d]+)/.exec(b.innerText);
  4253. if (match) {
  4254. const [, width, height] = match;
  4255. widths.push(Number(width));
  4256. heights.push(Number(height));
  4257. }
  4258. }
  4259.  
  4260. // add potential upscale warning
  4261. const multiple = integer_multiple(...widths);
  4262. if (multiple === integer_multiple(...heights)) {
  4263. const span = document.createElement('SPAN');
  4264. span.className = 'SA-warning';
  4265. span.innerHTML = ` ${WARNING_TEXT} potential ${multiple}x upscale`;
  4266. res.insertAdjacentElement('beforeend', span);
  4267. }
  4268. }
  4269.  
  4270. function add_custom_duplicate_delete_reason() {
  4271. const reason = document.getElementById('reason');
  4272. const custom_reason = document.getElementById('custom_reason');
  4273.  
  4274. const thumbs = [...document.querySelectorAll('#content > .deleting-post .thumb')];
  4275. if (thumbs.length !== 2)
  4276. return; // no parent
  4277.  
  4278. const parent_id = thumbs[1].id.substring(1);
  4279.  
  4280. const custom_dupe_option = document.createElement('OPTION');
  4281. custom_dupe_option.innerText = `duplicate of ${parent_id} (custom reason)`;
  4282. reason.appendChild(custom_dupe_option);
  4283.  
  4284. reason.addEventListener('change', () => {
  4285. const i = reason.selectedIndex;
  4286. if (i === 0) return;
  4287.  
  4288. const is_custom_dupe = reason.options[i] === custom_dupe_option;
  4289.  
  4290. if (is_custom_dupe) {
  4291. reason.selectedIndex = 0;
  4292. custom_reason.value = `duplicate of ${parent_id} (...)`;
  4293. custom_reason.focus();
  4294. custom_reason.setSelectionRange(custom_reason.value.length - 4, custom_reason.value.length - 1);
  4295. }
  4296. });
  4297. }
  4298.  
  4299. function add_tags_copy_button() {
  4300. const tags_not_present = document.querySelector('#content > .deleting-post > ul > li:nth-child(7)');
  4301. if (tags_not_present === null) return;
  4302.  
  4303. let tags_diff = ' ';
  4304. for (const a of tags_not_present.getElementsByTagName('A')) {
  4305. const tag = a.innerText.replaceAll(' ', '_');
  4306. if (['duplicate', 'potential_duplicate'].includes(tag)) continue; // TODO: ignore all meta tags?
  4307. tags_diff += tag + ' ';
  4308. }
  4309.  
  4310. const button = document.createElement('BUTTON');
  4311. button.type = 'button';
  4312. button.innerText = 'Copy Tags';
  4313. button.onclick = () => set_clipboard(tags_diff);
  4314.  
  4315. tags_not_present.appendChild(button);
  4316. }
  4317.  
  4318. function add_post_edit_buttons() {
  4319. // cache thumbnail tags
  4320. for (const thumb of document.querySelectorAll('.thumb'))
  4321. get_post_from_thumb(thumb);
  4322.  
  4323. const thumbs = [...document.querySelectorAll('.deleting-post .thumb')];
  4324. for (const thumb of thumbs) {
  4325. const a = document.createElement('A');
  4326. a.innerText = '⚙';
  4327. a.style.fontSize = '120%';
  4328. a.href = '#';
  4329. a.onclick = () => {
  4330. open_post_edit_dialog(thumb);
  4331. return false;
  4332. };
  4333. thumb.appendChild(a);
  4334. }
  4335. }
  4336.  
  4337. function add_moderation_search_template() {
  4338. const query = document.getElementById('query');
  4339. const select = create_template_dropdown(new Map([
  4340. ['Pending Posts', {add: ['status:pending'], remove: ['order:recently_flagged']}],
  4341. ['Flagged Posts', {add: ['order:recently_flagged', '-status:pending'], remove: []}]
  4342. ]), (value) => {
  4343. select.selectedIndex = 0;
  4344.  
  4345. const search_tags = new Tags(query.value);
  4346. for (const tag of value.remove) {
  4347. search_tags.remove(tag);
  4348. }
  4349. for (const tag of value.add) {
  4350. search_tags.add(tag);
  4351. }
  4352.  
  4353. query.value = search_tags.toString() + ' ';
  4354. query.focus();
  4355. });
  4356.  
  4357. const search_button = document.querySelector('#content > form > button');
  4358. search_button.insertAdjacentElement('afterend', select);
  4359. search_button.insertAdjacentText('afterend', ' ');
  4360. }
  4361.  
  4362. function get_lang_path() {
  4363. const lang = get_cookie('locale');
  4364. return lang && lang !== 'en' ? '/' + lang : '';
  4365. }
  4366.  
  4367. function old_wiki_tag_links() {
  4368. if (!config.use_old_wiki) return;
  4369.  
  4370. const lang = get_lang_path();
  4371.  
  4372. const revert_beta_link = (a, is_tag_edit_link) => {
  4373. try {
  4374. const url = new URL(a.href);
  4375. if (url.hostname !== 'beta.sankakucomplex.com') return;
  4376. const tag = url.searchParams.get('tagName');
  4377.  
  4378. if (url.pathname.startsWith('/tag/history')) {
  4379. a.href = new URL(`${lang}/tag_histories?tag_name=${tag}`, document.location.origin).href;
  4380. } else if (url.pathname.startsWith('/tag/')) {
  4381. // tag index "Edit" link
  4382. if (is_tag_edit_link) {
  4383. // tag edit page still exists but needs id: /tags/<id>/edit, tag history has working edit link
  4384. a.href = new URL(`${lang}/tag_histories?tag_name=${tag}`, document.location.origin).href;
  4385. } else {
  4386. a.href = new URL(`${lang}/wiki/${tag}`, document.location.origin).href;
  4387. }
  4388. } else if (url.pathname === '/') {
  4389. const tags = url.searchParams.get('tags');
  4390. a.href = new URL(`${lang}/?tags=${tags}`, document.location.origin).href;
  4391. }
  4392. } catch (e) { console.error('[addon error] failed reverting beta link', a.href, e); }
  4393. };
  4394.  
  4395. if (General.page === Page.TagIndex) {
  4396. document.querySelectorAll('tbody td:nth-child(6) a').forEach(a => revert_beta_link(a, true));
  4397. document.querySelectorAll('tbody a').forEach(a => revert_beta_link(a));
  4398. } else {
  4399. document.querySelectorAll('.tooltip a').forEach(a => revert_beta_link(a));
  4400. }
  4401. }
  4402.  
  4403. function old_wiki_subnav_links() {
  4404. if (!config.use_old_wiki) return;
  4405.  
  4406. const lang = get_lang_path();
  4407.  
  4408. for (const a of document.querySelectorAll('#subnavbar a')) {
  4409. const url = new URL(a.href);
  4410. if (url.hostname !== 'beta.sankakucomplex.com') continue;
  4411.  
  4412. if (a.pathname === '/wiki/edit_article') {
  4413. const tag = url.searchParams.get('tagName');
  4414. a.href = new URL(`${lang}/wiki/edit?title=${tag}`, document.location.origin).href;
  4415. } else if (a.pathname === '/wiki/article_history') {
  4416. // TODO this currently points to the history tag wiki
  4417. //a.href = new URL(`${lang}/wiki/history?title=${tag}`, document.location.origin).href;
  4418. a.style.color = 'red';
  4419. } else if (a.pathname === '/wiki/create_article') {
  4420. a.href = new URL(`${lang}/wiki/new`, document.location.origin).href;
  4421. } else if (a.pathname === '/') {
  4422. const tag = url.searchParams.get('tags');
  4423. a.href = new URL(`${lang}/?tags=${tag}`, document.location.origin).href;
  4424. }
  4425. }
  4426. }
  4427.  
  4428. function old_pools_post_link() {
  4429. if (!config.use_old_pools) return;
  4430.  
  4431. for (const el of document.querySelectorAll('.status-notice')) {
  4432. if (el.id.startsWith('pool')) {
  4433. const pool_id = el.id.substring(4);
  4434.  
  4435. for (const a of el.querySelectorAll('a')) {
  4436. const url = new URL(a.href);
  4437. if (url.hostname !== 'beta.sankakucomplex.com') continue;
  4438.  
  4439. a.href = new URL(`${get_lang_path()}/pools/${pool_id}`, document.location.origin).href;
  4440. break;
  4441. }
  4442. }
  4443. }
  4444. }
  4445.  
  4446. function old_nav_links() {
  4447. for (const a of document.querySelectorAll('#navbar a')) {
  4448. const url = new URL(a.href);
  4449. if (url.hostname !== 'beta.sankakucomplex.com') continue;
  4450.  
  4451. let reset_color = false;
  4452.  
  4453. if (config.use_old_pools && a.pathname === '/books') {
  4454. a.href = new URL(`${get_lang_path()}/pools`, document.location.origin).href;
  4455. reset_color = true;
  4456. } else if (config.use_old_tags_index && a.pathname === '/tags') {
  4457. a.href = new URL(`${get_lang_path()}/tags`, document.location.origin).href;
  4458. reset_color = true;
  4459. }
  4460.  
  4461. if (reset_color) {
  4462. const font = a.querySelector('font');
  4463. if (font) font.style.color = 'unset';
  4464. }
  4465. }
  4466. }
  4467.  
  4468.  
  4469. /******************/
  4470. /* document-start */
  4471. /******************/
  4472.  
  4473. await load_config();
  4474.  
  4475. let pathname = window.location.pathname;
  4476.  
  4477. // strip language codes in pathnames like "/ja/post/show"
  4478. if (pathname.indexOf('/', 1) === 3) pathname = pathname.substring(3);
  4479.  
  4480. // normalize old singulars to plurals
  4481. for (const path of ['post', 'user', 'user_record', 'tag']) {
  4482. if (pathname.startsWith(`/${path}/`) || pathname === '/' + path)
  4483. pathname = `/${path}s` + pathname.slice(5);
  4484. }
  4485.  
  4486. // normalize post index
  4487. if (pathname.startsWith('/posts/index') || pathname === '/posts')
  4488. pathname = '/';
  4489.  
  4490. const segments = pathname.split('/').filter(Boolean);
  4491.  
  4492. // match_segments('posts', 'delete') will match 'posts/[.../.../]delete[/...]'
  4493. function match_segments(...ordered) {
  4494. let i = 0;
  4495.  
  4496. return ordered.every(m => {
  4497. // search next segment
  4498. while (i < segments.length) {
  4499. if (segments[i++] === m) {
  4500. return true;
  4501. }
  4502. }
  4503.  
  4504. return false;
  4505. });
  4506. }
  4507.  
  4508. // detect page
  4509. if (pathname === '/' || (match_segments('posts') && segments.length === 1)
  4510. || match_segments('posts', 'similar')) General.page = Page.Index;
  4511. else if (match_segments('posts', 'delete')) General.page = Page.Delete;
  4512. else if (match_segments('posts', 'moderate')) General.page = Page.Moderate;
  4513. else if (match_segments('posts', 'upload')) General.page = Page.Upload;
  4514. else if (match_segments('posts')) General.page = Page.Post;
  4515. else if (match_segments('pools')) General.page = Page.Pool;
  4516. else if (match_segments('wiki', 'new')) General.page = Page.WikiNew;
  4517. else if (match_segments('wiki', 'edit')) General.page = Page.WikiEdit;
  4518. else if (match_segments('wiki') && segments.length !== 1) General.page = Page.WikiShow;
  4519. else if (match_segments('tags', 'edit')) General.page = Page.Tag;
  4520. else if (match_segments('tags')) General.page = Page.TagIndex;
  4521. else if (match_segments('users')) General.page = Page.User;
  4522. else if (match_segments('user_records', 'new')) General.page = Page.AddRecord;
  4523.  
  4524. // listen for config changes in other windows
  4525. add_storage_change_listener();
  4526.  
  4527. modify_css();
  4528.  
  4529. // add thumbnail icons and fade out thumbnails
  4530. modify_nodes('img.post-preview-image', modify_thumbnail, '.content-page, #recommendations');
  4531.  
  4532. switch (General.page) {
  4533. case Page.Post:
  4534.  
  4535. // mute/pause video
  4536. modify_nodes('video#image', node => { configure_video(node); return true; });
  4537.  
  4538. break;
  4539. }
  4540.  
  4541.  
  4542.  
  4543.  
  4544. /******************/
  4545. /* content-loaded */
  4546. /******************/
  4547.  
  4548. async function init() {
  4549. IndexPage.init();
  4550. PostPage.init();
  4551. WikiPage.init();
  4552. PoolPage.init();
  4553.  
  4554. add_config_dialog();
  4555. if (IS_MONKEY) GM.registerMenuCommand('Open Addon Config', () => show_config_dialog(true), 'C');
  4556. add_config_button();
  4557. update_config_dialog();
  4558.  
  4559. update_headerlogo();
  4560. useful_beta_link();
  4561. old_wiki_tag_links();
  4562. old_wiki_subnav_links();
  4563. old_nav_links();
  4564.  
  4565. switch (General.page) {
  4566. case Page.Index:
  4567. add_mode_options();
  4568. add_tagscript_presets();
  4569.  
  4570. collect_tag_categories();
  4571. if (config.tag_search_buttons) add_tag_search_buttons();
  4572. if (config.tag_post_counts) add_tag_post_counts();
  4573. add_postmode_hotkeys();
  4574.  
  4575. // TODO Edit Tags mode currently broken
  4576. // add_post_edit_dialog();
  4577. // update_tag_elements(); // initialize tag menu/buttons
  4578. // add_tags_change_listener();
  4579.  
  4580. break;
  4581.  
  4582. case Page.Post:
  4583. // reading the post id can fail when rate limited (or when the website changes)
  4584. if (config.view_history_enabled && PostPage.post_id !== null) {
  4585. config[HISTORY_KEY].add(PostPage.post_id);
  4586. save_setting(HISTORY_KEY, config[HISTORY_KEY]); // save and broadcast view history
  4587. }
  4588.  
  4589. collect_tag_categories();
  4590. if (config.tag_search_buttons) add_tag_search_buttons();
  4591. if (config.tag_post_counts) add_tag_post_counts();
  4592. if (config.tag_category_collapser) add_tag_category_collapser();
  4593. add_addon_actions(find_actions_list());
  4594.  
  4595. add_flagger_links();
  4596. link_to_post_tag_history();
  4597.  
  4598. fix_source_link();
  4599. if (config.move_stats_to_edit_form) move_stats_to_edit_form();
  4600.  
  4601. add_tag_buttons('edit-form');
  4602. if (config.tag_menu) add_tag_menu();
  4603. update_tag_elements(); // initialize tag menu/buttons
  4604. add_tags_change_listener();
  4605. add_tags_submit_listener(); // specifically for edit-form
  4606.  
  4607. add_postpage_hotkeys();
  4608. if (config.scale_on_resize) add_scale_on_resize_listener();
  4609.  
  4610. if (config.redirect_v_to_s_server) redirect_v_to_s_server();
  4611. read_image_data().then(() => {
  4612. if (config.scale_image) scale_image(config.scale_mode, false);
  4613. if (config.scroll_to_image) scroll_to_image();
  4614. add_resize_notice_listener();
  4615. add_highres_listener();
  4616. load_highres();
  4617. });
  4618.  
  4619. old_pools_post_link();
  4620.  
  4621. break;
  4622.  
  4623. case Page.WikiNew:
  4624. case Page.WikiEdit:
  4625. add_wiki_template();
  4626.  
  4627. break;
  4628.  
  4629. case Page.WikiShow:
  4630. add_tag_edit_gear();
  4631.  
  4632. break;
  4633.  
  4634. case Page.Tag:
  4635. add_tag_history_link();
  4636.  
  4637. break;
  4638.  
  4639. case Page.User:
  4640. add_status_post_links();
  4641.  
  4642. break;
  4643.  
  4644. case Page.AddRecord:
  4645. add_record_template();
  4646.  
  4647. break;
  4648.  
  4649. case Page.Delete:
  4650. // TODO pretty much all of this is broken
  4651. document.getElementById('custom_reason').style.minWidth = '25%';
  4652. //add_custom_duplicate_delete_reason();
  4653. //add_tags_copy_button();
  4654.  
  4655. //add_post_edit_dialog();
  4656. //add_post_edit_buttons();
  4657. //update_tag_elements(); // initialize tag menu/buttons
  4658. //add_tags_change_listener();
  4659.  
  4660. //deletion_sanity_checks();
  4661.  
  4662. break;
  4663.  
  4664. case Page.Moderate:
  4665. add_moderation_search_template();
  4666.  
  4667. break;
  4668. }
  4669. }
  4670.  
  4671. if (document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') {
  4672. init().catch((reason) => {
  4673. show_notice(console.error, '[addon error] init() failed, check console', reason);
  4674. });
  4675. } else {
  4676. document.addEventListener('DOMContentLoaded', init, false);
  4677. }
  4678. })(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);