garyc.me sketch tweaks

QoL tweaks and personal mods for garyc.me/sketch

  1. // ==UserScript==
  2. // @name garyc.me sketch tweaks
  3. // @namespace garyc.me by quackbarc
  4. // @description QoL tweaks and personal mods for garyc.me/sketch
  5. // @homepage https://github.com/quackbarc/garyc-sketch-tweaks
  6. // @author quac
  7. // @version 1.6.5
  8. // @match https://garyc.me/sketch/*
  9. // @match http*://noz.rip/sketch/*
  10. // @match http*://noz.rip/sketch_bunker/*
  11. // @icon https://raw.githubusercontent.com/quackbarc/garyc-sketch-tweaks/master/crunge.png
  12. // @license MIT
  13. // @run-at document-body
  14. // @grant none
  15. // ==/UserScript==
  16.  
  17. /* TODO:
  18. - animation speed setting..?
  19. - improve tag autocomplete caching..?
  20. - narrow down _purgeIntervals() to just the necessary intervals?
  21. cuz it might consequently affect other extensions.
  22.  
  23. - sketch: update():
  24. - update the UI with updateUI(State.IDLE)
  25. - fix animation ending one line too early
  26. - fix animation using the moveTo/lineTo way of drawing
  27.  
  28. - debug:
  29. - having the viewer open takes up a lot of CPU for some reason; i'm blaming pixi.
  30. */
  31.  
  32. /* / */
  33.  
  34. const GARYC_GALLERY_CLIENT = "garyc.me/sketch/gallery.php";
  35. const NOZ_GALLERY_CLIENT = "noz.rip/sketch/gallery.php";
  36. const NOZBUNKER_GALLERY_CLIENT = "noz.rip/sketch_bunker/gallery.php";
  37.  
  38. const client = window.location.hostname + window.location.pathname;
  39. const baseURL = client.startsWith("noz.rip/sketch_bunker/")
  40. ? "https://noz.rip/sketch_bunker"
  41. : "https://garyc.me/sketch";
  42.  
  43. var settings = {};
  44.  
  45. if(window.location.pathname.startsWith("/sketch")) {
  46. let db = new URLSearchParams(window.location.search).get("db");
  47. window.db = db && parseInt(db); // db can be `null`
  48. }
  49.  
  50. // Using a custom implementation of GM_addStyle instead of giving a @grant GM_addstyle;
  51. // the latter limits our access to `window` properties very greatly.
  52. // Implementation gracefully snagged from https://gist.github.com/arantius/3123124/ (MIT).
  53. /**
  54. * @param {string} aCss The CSS to append to the page, specifically <head\>.
  55. */
  56. function GM_addStyle(aCss) {
  57. 'use strict';
  58. let head = document.getElementsByTagName('head')[0];
  59. if (head) {
  60. let style = document.createElement('style');
  61. style.setAttribute('type', 'text/css');
  62. style.textContent = aCss;
  63. head.appendChild(style);
  64. return style;
  65. }
  66. return null;
  67. }
  68.  
  69. async function _sleep(ms) {
  70. return new Promise(res => setTimeout(res, ms));
  71. }
  72.  
  73. function _purgeIntervals() {
  74. const lastInterval = setTimeout(() => void 0, 0) - 1;
  75. for(let int = 0; int <= lastInterval; int++) {
  76. clearInterval(int);
  77. }
  78. }
  79.  
  80. function _getSettings() {
  81. let defaultSettings = {
  82. cacheSize: 100,
  83. theme: "auto",
  84. noAnimation: false,
  85. doReplay: true,
  86. thumbQuality: "default",
  87. sketchQuality: "default",
  88. relativeTimestamps: true,
  89. showDatecards: true, // on the UI, these would be called "time cards"
  90. saveAsCanvas: false,
  91. sketchSaveResolution: 1,
  92. showStats: true,
  93. supportApril2023: true,
  94. };
  95. if(window.location.hostname == "noz.rip") {
  96. defaultSettings = {
  97. ...defaultSettings,
  98. useArchiveAsBooruSource: true,
  99. samePageBooru: true,
  100. showingBooruMenu: false,
  101. // noz.rip has its own cache with a limited size; gotta be faithful with it.
  102. cacheSize: 10,
  103. };
  104. }
  105.  
  106. let settings = {};
  107. let storedSettings = JSON.parse(localStorage.getItem("settings_sketch")) || {};
  108. for(const [setting, defaultValue] of Object.entries(defaultSettings)) {
  109. settings[setting] = storedSettings[setting] ?? defaultValue;
  110. }
  111.  
  112. return settings;
  113. }
  114.  
  115. function _saveSettings() {
  116. localStorage.setItem("settings_sketch", JSON.stringify(settings));
  117. }
  118.  
  119. function _updateTheme() {
  120. switch(settings.theme) {
  121. case "auto": {
  122. let prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
  123. document.documentElement.setAttribute("theme", prefersDark ? "dark" : "light");
  124. break;
  125. }
  126. case "dark":
  127. case "light": {
  128. document.documentElement.setAttribute("theme", settings.theme);
  129. break;
  130. }
  131. default: {
  132. document.documentElement.setAttribute("theme", "light");
  133. }
  134. }
  135. }
  136.  
  137. function _updateSketchQuality(quality) {
  138. const ctx = $("canvas")[0].getContext("2d");
  139.  
  140. switch(quality) {
  141. case "spiky": {
  142. ctx.lineJoin = "miter";
  143. break;
  144. }
  145. case "default":
  146. default: {
  147. ctx.lineJoin = "round";
  148. break;
  149. }
  150. }
  151. }
  152.  
  153. /* /: Main */
  154.  
  155. function main() {
  156. _purgeIntervals();
  157. settings = _getSettings();
  158.  
  159. GM_addStyle(`
  160. /* dark theme */
  161. :root[theme="dark"] body {
  162. background-color: #111;
  163. color: #ccc;
  164. }
  165. :root[theme="dark"] #holder {
  166. background-color: #191919;
  167. }
  168. :root[theme="dark"] #holder img:not([src^=save]) {
  169. filter: invert(90%);
  170. }
  171. :root[theme="dark"] input[type="submit" i]:disabled button:disabled {
  172. background-color: #fff3;
  173. color: #fff8
  174. }
  175. :root[theme="dark"] h1 {
  176. color: #eee;
  177. }
  178. :root[theme="dark"] a {
  179. color: #5c99ff;
  180. }
  181. :root[theme="dark"] a:hover {
  182. color: #5c99ffcc;
  183. }
  184. :root[theme="dark"] a:visited {
  185. color: #8c1ae9;
  186. }
  187. :root[theme="dark"] a:visited:hover {
  188. color: #8c1ae9cc;
  189. }
  190.  
  191. /* noz.rip */
  192. :root[theme="dark"] .panel {
  193. border-color: #888;
  194. }
  195. :root[theme="dark"] #holder svg {
  196. stroke: #e5e5e5;
  197. }
  198.  
  199. /* userscript-created elements */
  200. :root {
  201. --z-index-dropdown: 10;
  202. --background-tag-suggestions: #fff;
  203. --background-tag-suggestions-selected: #eee;
  204. }
  205. :root[theme="dark"] {
  206. --background-tag-suggestions: #111;
  207. --background-tag-suggestions-selected: #222;
  208. }
  209. `);
  210. _updateTheme();
  211. }
  212.  
  213. main();
  214.  
  215. /* /sketch/gallery.php */
  216.  
  217. const booruStates = {};
  218. const cache = {};
  219. let lastAlertPromise = null;
  220. let lastAutocompletePromise = null;
  221. let lastAutocompleteQuery = null;
  222. let lastTagsValue = null;
  223. let autocompleteSelected = null;
  224. let cachedCanvasBlob = null;
  225. let datecardDates = new Map();
  226. window.details = null;
  227.  
  228. // enums
  229.  
  230. const BooruPostState = {
  231. POSTED: 1,
  232. ALREADY_POSTED: 2,
  233. PARSING_ERROR: 3,
  234. }
  235.  
  236. const FooterState = {
  237. NORMAL: 0,
  238. END_OF_GALLERY: 1,
  239. };
  240.  
  241. // miscellaneous methods
  242.  
  243. function _getAprilFoolsColor(id) {
  244. const index = [
  245. "#4B0082", // purple
  246. "#0000FF", // blue
  247. "#008000", // dark green
  248. "#FFFF00", // yellow
  249. "#FFA500", // orange
  250. "#FF0000", // red
  251. ];
  252.  
  253. return index[id % 6];
  254. }
  255.  
  256. function _getCurrentTag(tagsBar) {
  257. const cursorPos = tagsBar.selectionStart;
  258. // Match everything from the beginning of the tags value to the nth
  259. // character, and any word/part of word that comes immediately after it.
  260. // Using [^ \n] instead of just [^ ] just to match with what . captures.
  261. // Using {0,n} instead of {n} because I don't want match breakage
  262. // (from an n that's bigger than the search string).
  263. const pattern = new RegExp(`^.{0,${cursorPos}}[^ \n]*`);
  264.  
  265. const rawTags = tagsBar.value;
  266. const [rawTagsShort,] = rawTags.match(pattern);
  267. const tags = rawTagsShort.split(" ");
  268. const currentTag = tags.at(-1);
  269.  
  270. return currentTag;
  271. }
  272.  
  273. function toSVG(dat, linejoin="round") {
  274. const commands = [];
  275. for(const line of dat.split(" ")) {
  276. for(let ind = 0; ind + 4 <= line.length; ind += 4) {
  277. const [x, y] = [
  278. parseInt(line.slice(ind, ind+2), 36),
  279. parseInt(line.slice(ind+2, ind+4), 36)
  280. ];
  281. const command = ind == 0 ? `M${x},${y}` : `L${x},${y}`;
  282. commands.push(command);
  283. }
  284. }
  285.  
  286. const path = commands.join("");
  287. const xml = [
  288. '<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg">',
  289. '<path',
  290. `d="${path}"`,
  291. 'fill="none"',
  292. 'stroke="black"',
  293. 'stroke-width="3px"',
  294. 'stroke-miterlimit="10"',
  295. `stroke-linecap="butt"`,
  296. `stroke-linejoin="${linejoin}"/>`,
  297. '</svg>'
  298. ].join("\n");
  299. return xml;
  300. }
  301.  
  302. // UI and public API methods
  303.  
  304. function _tileAnchorOverride(event) {
  305. event.preventDefault();
  306.  
  307. const a = event.currentTarget;
  308. const idMatch = a.href.match(/#(\d+)/)
  309. const [hashID, id] = idMatch;
  310. window.history.pushState(window.history.state, "", hashID);
  311. show(parseInt(id));
  312. }
  313.  
  314. function _navAnchorOverride(event) {
  315. event.preventDefault();
  316.  
  317. const a = event.currentTarget;
  318. const idMatch = a.href.match(/#(\d+)/)
  319. const id = parseInt(idMatch[1]);
  320. show(id);
  321. }
  322.  
  323. function _getThumbSize(qualityName) {
  324. switch(qualityName) {
  325. case "awful":
  326. return 4;
  327. case "oldDefault":
  328. return 20;
  329. case "raster":
  330. return 20.1;
  331. case "hq":
  332. return 40;
  333. case "default":
  334. default:
  335. return 100;
  336. };
  337. }
  338.  
  339. function _getNozSVGAsset(type) {
  340. // These COULD be put on separate files for cacheability
  341. switch(type) {
  342. case "top": {
  343. return (`
  344. <svg
  345. fill="none"
  346. stroke="black"
  347. stroke-width="30"
  348. stroke-linejoin="round"
  349. xmlns="http://www.w3.org/2000/svg"
  350. viewBox="0 0 300 300"
  351. width="300" height="300">
  352. <circle cx="150" cy="150" r="135"></circle>
  353. <path d="${[
  354. "M 95,75 L 205,225 z",
  355. "M 205,75 L 95,225 z"
  356. ].join(" ")}">
  357. </path>
  358. </svg>
  359. `);
  360. }
  361. case "left": {
  362. return (`
  363. <svg
  364. fill="none"
  365. stroke="black"
  366. stroke-width="20"
  367. xmlns="http://www.w3.org/2000/svg"
  368. viewBox="0 0 300 300"
  369. width="300" height="300">
  370. <path d="${[
  371. "M 180,30 L 16,150 L 180,270",
  372. "V 200 H 290 V 100 H 180 V 30 z"
  373. ].join(" ")}">
  374. </path>
  375. </svg>
  376. `);
  377. }
  378. case "right": {
  379. return (`
  380. <svg
  381. fill="none"
  382. stroke="black"
  383. stroke-width="20"
  384. xmlns="http://www.w3.org/2000/svg"
  385. viewBox="0 0 300 300"
  386. width="300" height="300">
  387. <path d="${[
  388. "M 120,30 L 284,150 L 120,270",
  389. "V 200 H 10 V 100 H 120 V 30 z",
  390. ].join(" ")}">
  391. </path>
  392. </svg>
  393. `);
  394. }
  395. default: {
  396. throw Error(`unknown asset type "${type}"`);
  397. }
  398. }
  399. }
  400.  
  401. function getTile(id) {
  402. let imgURL;
  403. if(client == NOZBUNKER_GALLERY_CLIENT) {
  404. imgURL = `getIMG.php?id=${id}`;
  405. } else {
  406. let size = _getThumbSize(settings.thumbQuality);
  407. let dbParam = window.db != null ? `&db=${window.db}` : "";
  408. imgURL = `https://garyc.me/sketch/getIMG.php?format=png${dbParam}&id=${id}&size=${size}`;
  409. }
  410.  
  411. const tile = $([
  412. `<a href="#${id}">`,
  413. `<img src="${imgURL}" style="`,
  414. `padding: 5px;`,
  415. `width: 160px;`,
  416. `height: 120px;`,
  417. `"></a>`,
  418. ].join(""));
  419. tile.click(_tileAnchorOverride);
  420.  
  421. return tile;
  422. }
  423.  
  424. function createDateCard(dt) {
  425. let weekday = dt.toLocaleString("default", {weekday: "long"});
  426. let date = dt.toLocaleString("default", {month: "long", day: "numeric", year: "numeric"});
  427. return $(`
  428. <div class="datecard">
  429. <div>
  430. ${weekday}<br>${date}
  431. </div>
  432. </div>
  433. `);
  434. }
  435.  
  436. function currentURL() {
  437. const client = window.location.hostname + window.location.pathname;
  438. if(window.db != null) {
  439. return `https://${client}?db=${window.db}#${window.current}`;
  440. } else {
  441. return `https://${client}#${window.current}`;
  442. }
  443. }
  444.  
  445. function currentArchiveURL() {
  446. if(window.db != null) {
  447. return null;
  448. } else {
  449. return `https://noz.rip/sketch_bunker/gallery.php?maxid=${window.current}#${window.current}`;
  450. }
  451. }
  452.  
  453. function updateDetails(options={}) {
  454. if(window.current == null) {
  455. return;
  456. }
  457.  
  458. const defaultOptions = {
  459. message: null,
  460. showFullTimestamp: false,
  461. };
  462. const mergedOptions = {...defaultOptions, ...options};
  463. const {message, showFullTimestamp} = mergedOptions;
  464.  
  465. const unavailable = (window.dat == "wait" || window.dat == "wait "); // thanks drawData();
  466. let elems = [];
  467.  
  468. if(message != null) {
  469. elems.push(message);
  470. } else if(unavailable) {
  471. elems.push("(unavailable)");
  472. } else {
  473. let ink = Math.floor(window.dat.length / 65535 * 100);
  474. let inkText = `${ink}% ink used`;
  475. elems.push(inkText);
  476. }
  477.  
  478. // This build custom HTML for the URL, unlike currentURL(), which only
  479. // returns it as a string.
  480. let client = window.location.hostname + window.location.pathname;
  481. let current = `<span class="id">#${window.current}</span>`;
  482. let url = (
  483. window.db != null
  484. ? `https://${client}?db=${window.db}${current}`
  485. : `https://${client}${current}`
  486. );
  487. elems.push(url);
  488.  
  489. const hasSketchDetails = window.details.origin || (window.details.timestamp != null); // for timestamp=0 ig
  490. if(hasSketchDetails) {
  491. let origin = window.details.origin;
  492. let date = new Date(window.details.timestamp * 1000);
  493. let timestamp = date
  494. .toLocaleString("default", {
  495. weekday: "short",
  496. month: "long",
  497. day: "2-digit",
  498. year: "numeric",
  499. hour: "2-digit",
  500. minute: "2-digit",
  501. });
  502. let timestampTooltip = date
  503. .toLocaleString("default", {
  504. weekday: "short",
  505. month: "long",
  506. day: "2-digit",
  507. year: "numeric",
  508. hour: "2-digit",
  509. minute: "2-digit",
  510. second: "2-digit",
  511. timeZoneName: "short",
  512. });
  513. if(settings.relativeTimestamps) {
  514. const today = new Date();
  515. const yesterday = new Date(today - 86_400_000);
  516. const dateOptions = {
  517. weekday: "short",
  518. month: "long",
  519. day: "2-digit",
  520. year: "numeric",
  521. };
  522. timestamp = timestamp
  523. .replace(today.toLocaleString("default", dateOptions), "Today")
  524. .replace(yesterday.toLocaleString("default", dateOptions), "Yesterday");
  525.  
  526. const weekdayMin = new Date();
  527. weekdayMin.setDate(today.getDate() - 6);
  528. weekdayMin.setHours(0, 0, 0, 0);
  529.  
  530. if(date >= weekdayMin) {
  531. timestamp = timestamp
  532. .replace(
  533. date.toLocaleString("default", dateOptions),
  534. date.toLocaleString("default", {weekday: "long"})
  535. );
  536. }
  537. }
  538.  
  539. let timestampHTML = `<span title="${timestampTooltip}">${timestamp}</span>`;
  540. if(showFullTimestamp) {
  541. timestampHTML = `<span>${timestampTooltip}</span>`;
  542. }
  543.  
  544. let detailsText = `from ${origin} ${timestampHTML}`;
  545. if(origin == null) {
  546. detailsText = timestampHTML;
  547. }
  548. let detailsHTML = `<span class="extra">${detailsText}</span>`
  549.  
  550. elems.push(detailsHTML);
  551. }
  552.  
  553. switch(client) {
  554. case NOZ_GALLERY_CLIENT: {
  555. const left = $(`<div id="details-left"></div>`);
  556. const right = $(`<div id="details-right"></div>`);
  557.  
  558. const [booruForm, booruToggle] = createBooruFormUI(window.current);
  559.  
  560. $("#details").empty();
  561. $("#details").append(left, right);
  562. left.append(elems.join("<br>"));
  563. right.append(booruForm, booruToggle);
  564. break;
  565. }
  566. default: {
  567. $("#details").empty();
  568. $("#details").append(elems.join("<br>"));
  569. }
  570. }
  571.  
  572. $(".extra span[title]").click(() => detailsFullTimestamp());
  573. }
  574.  
  575. async function detailsAlert(msg) {
  576. updateDetails({message: msg});
  577. let alertPromise = lastAlertPromise = _sleep(3000);
  578. await alertPromise;
  579. if(alertPromise === lastAlertPromise) {
  580. updateDetails();
  581. }
  582. }
  583.  
  584. async function detailsFullTimestamp() {
  585. updateDetails({showFullTimestamp: true});
  586. let alertPromise = lastAlertPromise = _sleep(3000);
  587. await alertPromise;
  588. if(alertPromise === lastAlertPromise) {
  589. updateDetails();
  590. }
  591. }
  592.  
  593. function createStats() {
  594. const stats = $(`<span id="stats">...</span>`);
  595. return stats;
  596. }
  597.  
  598. function updateStats(json) {
  599. const {sketches, artists, peekers} = json;
  600. let es_were = sketches == 1 ? " was" : "es were";
  601. let different_artists = artists == 1 ? " artist" : "different artists";
  602. let were = peekers == 1 ? "was" : "were";
  603. let people = peekers == 1 ? "person" : "people";
  604.  
  605. $("#stats").html(
  606. "In the past 5 minutes, "
  607. + `<b>${sketches}</b> sketch${es_were} swapped by `
  608. + `<b>${artists}</b> ${different_artists}. There ${were} also `
  609. + `<b>${peekers}</b> ${people} who only peeked.`
  610. );
  611. }
  612.  
  613. function createGalleryButtons(id) {
  614. let topAsset, leftAsset, rightAsset;
  615. switch(window.location.hostname) {
  616. case "noz.rip": {
  617. topAsset = _getNozSVGAsset("top");
  618. leftAsset = _getNozSVGAsset("left");
  619. rightAsset = _getNozSVGAsset("right");
  620. break;
  621. }
  622. default: {
  623. topAsset = `<img src="https://garyc.me/sketch/top.png">`;
  624. leftAsset = `<img src="https://garyc.me/sketch/left.png">`;
  625. rightAsset = `<img src="https://garyc.me/sketch/right.png">`;
  626. }
  627. }
  628.  
  629. let leftID = Math.max(window.min, id + 1);
  630. let rightID = Math.min(window.max, id - 1);
  631.  
  632. var top = `<a href="#0" onclick="hide()" class="top">${topAsset}</a>`;
  633. var leftReg = `<a href="#${leftID}" class="left">${leftAsset}</a>`;
  634. var leftMax = `<div class="left"></div>`;
  635. var rightReg = `<a href="#${rightID}" class="right">${rightAsset}</a>`;
  636. var rightMin = `<div class="right"></div>`;
  637. var left = id >= window.max ? leftMax : leftReg;
  638. var right = id <= window.min ? rightMin : rightReg;
  639.  
  640. return {
  641. top: top,
  642. left: left,
  643. right: right,
  644. };
  645. }
  646.  
  647. function updateGalleryButtons() {
  648. if(window.current == null) {
  649. return;
  650. }
  651.  
  652. const {top, left, right} = createGalleryButtons(window.current);
  653. $(".top").replaceWith(top);
  654. $(".left").replaceWith(left);
  655. $(".right").replaceWith(right);
  656. }
  657.  
  658. function saveBooruChanges(id, form) {
  659. if(!booruStates.hasOwnProperty(id)) {
  660. booruStates[id] = {
  661. booruPostID: null,
  662. booruPostStatus: null,
  663. uploading: false,
  664. tags: null,
  665. rating: null,
  666. };
  667. }
  668.  
  669. const tagsBar = form.find("input[name='tags']");
  670. const ratingSelect = form.find("select#rating");
  671.  
  672. const state = booruStates[id];
  673. state.tags = tagsBar.val();
  674. state.rating = ratingSelect.val();
  675. }
  676.  
  677. async function getDateCards(endID, size) {
  678. if(size <= 0) {
  679. return [];
  680. }
  681.  
  682. let fromID = endID - size + 1;
  683. let toID = endID;
  684. let lastTimestamp = new Date();
  685.  
  686. var ret = [];
  687.  
  688. const fetchIDFrom = Math.ceil(fromID / 100) * 100;
  689. const fetchIDTo = Math.ceil(toID / 100) * 100;
  690. for(let fetchID = fetchIDTo; fetchID >= fetchIDFrom; fetchID -= 100) {
  691. let html = await fetch(`https://garyc.me/sketch/getMore.php?start=${fetchID}&db=${db || ""}`)
  692. .then(r => r.text());
  693.  
  694. // Parsing HTML with regex instead of making a document fragment,
  695. // since one, it's cleaner to write than the alternative, and two,
  696. // we won't get 404s from thumbnails of sketches that don't exist.
  697.  
  698. const htmlRegex = /class='timestamp'.+?>(?<timestamp>\d*)<\/div><a href=['"](?<href>#\d+)/g;
  699. for(const match of html.matchAll(htmlRegex)) {
  700. if(!match.groups.timestamp) {
  701. continue;
  702. }
  703.  
  704. let timestamp = new Date(match.groups.timestamp * 1000);
  705. let href = match.groups.href;
  706. let id = parseInt(href.replace("#", ""));
  707.  
  708. if(lastTimestamp.toDateString() != timestamp.toDateString()) {
  709. ret.push([timestamp, createDateCard(timestamp), id]);
  710. }
  711.  
  712. lastTimestamp = timestamp;
  713. }
  714. }
  715.  
  716. return ret;
  717. }
  718.  
  719. async function getDateCardMapping(last, size) {
  720. let datecards = {};
  721. for(const [timestamp, datecard, id] of await getDateCards(last, size)) {
  722. let date = timestamp.toDateString();
  723. datecards[id] = [datecard, date];
  724. }
  725. return datecards;
  726. }
  727.  
  728. async function saveCanvas() {
  729. if(window.current == null) {
  730. return;
  731. }
  732.  
  733. // Render the entire sketch first before saving
  734. window.setData(window.dat);
  735.  
  736. const scale = settings.sketchSaveResolution;
  737.  
  738. let downloadFn = window.db == null
  739. ? `${window.current}`
  740. : `${window.db}#${window.current}`;
  741. if(scale != 1) {
  742. downloadFn = `${downloadFn}_${scale}x`
  743. }
  744.  
  745. await scaleCanvas(scale);
  746.  
  747. const sketch = window.sketch[0];
  748. let blob = await new Promise((res, rej) => sketch.toBlob(blob => res(blob)));
  749. let url = URL.createObjectURL(blob);
  750.  
  751. const a = document.createElement("a");
  752. a.href = url;
  753. a.download = downloadFn;
  754. a.click();
  755.  
  756. URL.revokeObjectURL(url);
  757.  
  758. await scaleCanvas(1);
  759. }
  760.  
  761. function saveSVG() {
  762. if(window.current == null) {
  763. return;
  764. }
  765.  
  766. const linejoin = settings.sketchQuality == "spiky" ? "miter" : "round";
  767. const svg = toSVG(window.dat, linejoin);
  768.  
  769. const blob = new Blob([svg], {type: "image/svg+xml"});
  770. const url = URL.createObjectURL(blob);
  771. const a = document.createElement("a");
  772. a.download = `${window.current}.svg`;
  773. a.href = url;
  774. a.click();
  775. URL.revokeObjectURL(url);
  776. }
  777.  
  778. async function scaleCanvas(size) {
  779. const width = (800 * size) | 0;
  780. const height = (600 * size) | 0;
  781.  
  782. $("#sketch").attr({
  783. width: `${width}px`,
  784. height: `${height}px`
  785. });
  786. graphics.setTransform(0, 0, size, size);
  787.  
  788. // For some reason, the lineJoin would revert itself back to "miter", the default.
  789. _updateSketchQuality(settings.sketchQuality);
  790.  
  791. // Give the canvas some time to do the resize before we return
  792. await new Promise((res, rej) => window.requestAnimationFrame(res));
  793. }
  794.  
  795. // Booru and tag autocomplete methods (for noz.rip/booru)
  796.  
  797. async function selfUploadToBooru(id, form) {
  798. // Form can only be serialized before it gets disabled.
  799. const formSerial = form.serialize();
  800.  
  801. saveBooruChanges(id, form);
  802. const booruState = booruStates[id];
  803.  
  804. booruState.uploading = true;
  805. updateDetails();
  806.  
  807. let resp = await fetch(
  808. "/booru/upload",
  809. {
  810. method: "POST",
  811. body: new URLSearchParams(formSerial),
  812. }
  813. );
  814.  
  815. const uploadSuccessful = resp.redirected;
  816. const loggedOut = resp.status == 403;
  817.  
  818. if(loggedOut) {
  819. booruState.uploading = false;
  820. detailsAlert("can't upload; logged out of booru");
  821. return;
  822. }
  823.  
  824. if(uploadSuccessful) {
  825. const match = resp.url.match(/\/view\/(\d+)/);
  826. const postID = parseInt(match[1]);
  827. booruState.booruPostID = postID;
  828. booruState.booruPostStatus = BooruPostState.POSTED;
  829. }
  830. else {
  831. // Until I find a way to properly check for errors and hash duplicates through the wire,
  832. // this will have to do.
  833.  
  834. const idPattern = /data-post-id='(\d+)'/;
  835.  
  836. const text = await resp.text();
  837. const match = text.match(idPattern);
  838. if(!match) {
  839. const doc = new DOMParser().parseFromString(text, "text/html");
  840.  
  841. const xEmptyErrorElem = $(doc).find("section[id^=Error_with] .blockbody");
  842. const generalErrorElem = $(doc).find("section[id^=Error] .blockbody");
  843.  
  844. const isXEmptyError = xEmptyErrorElem.length > 0;
  845. const isGeneralError = generalErrorElem.length > 0;
  846. if(isXEmptyError) {
  847. booruState.uploading = false;
  848. detailsAlert("can't upload; unavailable sketch");
  849. return;
  850. }
  851. else if(isGeneralError) {
  852. const errorMessage = generalErrorElem.text();
  853.  
  854. booruState.uploading = false;
  855. detailsAlert(`booru error: ${errorMessage}`);
  856. return;
  857. }
  858. else {
  859. console.error("Unexpected response from Shimmie:", doc);
  860. booruState.booruPostStatus = BooruPostState.PARSING_ERROR;
  861. }
  862. }
  863. else {
  864. const postID = parseInt(match[1]);
  865. booruState.booruPostID = postID;
  866. booruState.booruPostStatus = BooruPostState.ALREADY_POSTED;
  867. }
  868. }
  869.  
  870. booruState.uploading = false;
  871. if(window.current == id) {
  872. updateDetails();
  873. }
  874. }
  875.  
  876. async function hideTagSuggestions() {
  877. $("#tagSuggestions").hide();
  878. lastAutocompletePromise = null;
  879. lastAutocompleteQuery = null;
  880. autocompleteSelected = null;
  881. }
  882.  
  883. async function updateTagSuggestions() {
  884. const tagsBar = $("input[name='tags']");
  885. const tagsBarElement = tagsBar[0];
  886.  
  887. const currentTag = _getCurrentTag(tagsBarElement);
  888. if(!currentTag) {
  889. hideTagSuggestions();
  890. return;
  891. }
  892.  
  893. $("#tagSuggestions").hide();
  894.  
  895. let autocompletePromise = lastAutocompletePromise = _sleep(200);
  896. await autocompletePromise;
  897. if(autocompletePromise !== lastAutocompletePromise) {
  898. return;
  899. }
  900.  
  901. const baseURL = "https://noz.rip/booru/api/internal/autocomplete";
  902. const url = baseURL + "?s=" + currentTag;
  903. // Endpoint doesn't send caching instructions;
  904. // we're on our own here
  905. const cacheType = "reload";
  906.  
  907. let p = fetch(url, {cache: cacheType}).catch(err => err);
  908. let fetchPromise = lastAutocompletePromise = p;
  909. const resp = await fetchPromise;
  910. if(fetchPromise !== lastAutocompletePromise) {
  911. return;
  912. }
  913. lastAutocompletePromise = null;
  914.  
  915. // Network issue; ignore
  916. if(resp instanceof TypeError) {
  917. return;
  918. }
  919.  
  920. if(!resp.ok) {
  921. await autocompleteError(resp);
  922. return;
  923. }
  924.  
  925. const json = await resp.json();
  926. await autocompleteDropdown(json, currentTag);
  927. }
  928.  
  929. async function autocompleteError(response) {
  930. $("#tagSuggestions").show();
  931. $("#tagSuggestions").html(`
  932. <tr role="option" class="tagInfo">
  933. <td colspan="2">
  934. (something went wrong: ${response.status} ${response.statusText})
  935. </td>
  936. </tr>
  937. `);
  938. }
  939.  
  940. async function autocompleteDropdown(json, query) {
  941. const tagElements = [];
  942. let tags = Object.entries(json);
  943. if(json instanceof Array) {
  944. // For queries with zero results. Damn this API is terrible
  945. tags = json;
  946. }
  947.  
  948. if(tags.length == 0) {
  949. const element = $(`
  950. <tr role="option" class="tagInfo">
  951. <td colspan="2">
  952. (new tag: ${query})
  953. </td>
  954. </tr>
  955. `);
  956. tagElements.push(element);
  957. $("#tagSuggestions").show();
  958. $("#tagSuggestions").html(tagElements);
  959. }
  960.  
  961. const lastSelectedIndex = tags.findIndex(([name, count]) => name == autocompleteSelected);
  962. const selectedIsKept = lastSelectedIndex >= 0;
  963.  
  964. if(tags.length >= 1 && selectedIsKept) {
  965. autocompleteSelected = autocompleteSelected;
  966. }
  967. else if(tags.length >= 1 && !selectedIsKept) {
  968. autocompleteSelected = tags[0][0];
  969. }
  970. else if(tags.length == 0) {
  971. autocompleteSelected = null;
  972. }
  973.  
  974. const maxTagCount = 20;
  975. for(let i = 0; i < Math.min(tags.length, maxTagCount); i++) {
  976. const [name, result] = tags[i];
  977. const {count} = result;
  978. const element = $(`
  979. <tr role="option" name="${name}">
  980. <td class="tagName">${name}</td>
  981. <td class="tagCount">${count}</td>
  982. </tr>
  983. `);
  984.  
  985. // Don't lose focus off the tags bar.
  986. element.on("pointerdown", function(event) {
  987. const focusingTagsBar = $("input[name='tags']").has(":focus");
  988. return !focusingTagsBar;
  989. });
  990. element.on("pointerup", () => addTag(name, query));
  991.  
  992. element.on("pointerover", () => autocompleteSelect(name));
  993. element.attr("aria-selected", (name == autocompleteSelected).toString());
  994. tagElements.push(element);
  995. }
  996.  
  997. if(tags.length > maxTagCount) {
  998. const remainingTags = tags.slice(maxTagCount);
  999. const element = $(`
  1000. <tr role="option" class="tagInfo">
  1001. <td colspan="2">
  1002. (${remainingTags.length} more...)
  1003. </td>
  1004. </tr>
  1005. `);
  1006. tagElements.push(element);
  1007. }
  1008.  
  1009. $("#tagSuggestions").show();
  1010. $("#tagSuggestions").html(tagElements);
  1011. }
  1012.  
  1013. function autocompleteSelect(name) {
  1014. const option = $(`#tagSuggestions [name="${name}"]`);
  1015. const optionExists = option.length >= 1;
  1016. if(!optionExists) {
  1017. console.debug(`"${name}" doesn't exist in visible tags, ignoring that`);
  1018. return;
  1019. }
  1020.  
  1021. const optionLast = $(`#tagSuggestions [aria-selected]`);
  1022. optionLast.attr("aria-selected", "false");
  1023. option.attr("aria-selected", "true");
  1024.  
  1025. autocompleteSelected = name;
  1026. }
  1027.  
  1028. function addTag(name, query) {
  1029. const tagsBar = $("#booruForm input[name=tags]");
  1030. const rawTags = tagsBar.val();
  1031. const index = tagsBar.prop("selectionStart");
  1032.  
  1033. const [section,] = rawTags.match(new RegExp(`.{0,${index}}[^ ]*`));
  1034. let sectionTags = section.split(" ");
  1035. sectionTags[sectionTags.length - 1] = name;
  1036. sectionTags = sectionTags.join(" ");
  1037.  
  1038. const newIndex = sectionTags.length + 1;
  1039. const newTags = sectionTags + " " + rawTags.slice(section.length).trimLeft(" ");
  1040. tagsBar.prop("value", newTags);
  1041. tagsBar.prop("selectionStart", newIndex);
  1042. tagsBar.prop("selectionEnd", newIndex);
  1043.  
  1044. tagsBar.focus();
  1045. hideTagSuggestions();
  1046. }
  1047.  
  1048. // overrides
  1049.  
  1050. function gallery_update() {
  1051. if(autodrawpos >= 0) {
  1052. for(var i = 0; i < 8; i++) {
  1053. if(autodrawpos == lines.length) {
  1054. autodrawpos = -1;
  1055. break;
  1056. }
  1057. var line = lines[autodrawpos++];
  1058. if(line.moveTo) {
  1059. graphics.moveTo(line.x1, line.y1);
  1060. }
  1061. graphics.lineTo(line.x2, line.y2);
  1062. }
  1063. }
  1064. }
  1065.  
  1066. async function refresh() {
  1067. $("#refresh").prop("disabled", true);
  1068. $("#refresh").val("checking...");
  1069.  
  1070. function enableRefresh() {
  1071. $("#refresh").prop("disabled", false);
  1072. $("#refresh").val("refresh");
  1073. }
  1074.  
  1075. $.ajax({
  1076. url: `https://garyc.me/sketch/getStats.php?details&db=${db || ""}`,
  1077. dataType: "json",
  1078. success: function(json) {
  1079. updateStats(json);
  1080. const newMax = json.maxID;
  1081.  
  1082. // noz.rip: `window.max` can be fetched from a $.ajax() on init,
  1083. // but it's saved as a string. Firing this request a bit after
  1084. // the $.ajax() call SHOULD fix that on time.
  1085.  
  1086. const init = window.max == null || typeof window.max == "string";
  1087. if(init) {
  1088. window.max = newMax;
  1089. window.min = json.minID;
  1090. updateGalleryButtons();
  1091. return enableRefresh();
  1092. }
  1093.  
  1094. if(window.max == newMax) {
  1095. return enableRefresh();
  1096. }
  1097.  
  1098. for(let id = window.max + 1; id <= newMax; id++) {
  1099. $("#tiles").prepend(
  1100. $(getTile(id))
  1101. .hide()
  1102. .show(1000)
  1103. );
  1104. }
  1105.  
  1106. if(settings.showDatecards) {
  1107. // Max values are -1'd so that IDs ending with 00 are NOT
  1108. // equal to IDs ending with 01; the latter's where
  1109. // `addMore.php`'s thumbnails start.
  1110. let lastMax100 = Math.floor((window.max - 1) / 100);
  1111. let newMax100 = Math.floor((newMax - 1) / 100);
  1112. if(newMax100 > lastMax100) {
  1113. // Size is +1'd so the previous sketch gets a datecard
  1114. // when the current day changes.
  1115. addDateCards(newMax, newMax - window.max + 1);
  1116. }
  1117. }
  1118.  
  1119. const viewingLatestSketch = window.current == window.max;
  1120. window.max = newMax;
  1121. window.min = json.minID;
  1122.  
  1123. if(viewingLatestSketch) {
  1124. updateGalleryButtons();
  1125. }
  1126.  
  1127. enableRefresh();
  1128. },
  1129. error: function(req) {
  1130. enableRefresh();
  1131. },
  1132. });
  1133. }
  1134.  
  1135. async function nozBunker_refresh() {
  1136. if(window.customMax != null) {
  1137. return;
  1138. }
  1139.  
  1140. $("#refresh").prop("disabled", true);
  1141. $("#refresh").val("checking...");
  1142.  
  1143. function enableRefresh() {
  1144. $("#refresh").prop("disabled", false);
  1145. $("#refresh").val("refresh");
  1146. }
  1147.  
  1148. $.ajax({
  1149. url: `https://noz.rip/sketch_bunker/getMaxID.php`,
  1150. dataType: "text",
  1151. success: function(resp) {
  1152. const newMax = parseInt(resp);
  1153. if(window.max == newMax) {
  1154. return enableRefresh();
  1155. }
  1156.  
  1157. for(let id = window.max + 1; id <= newMax; id++) {
  1158. $("#tiles").prepend(
  1159. $(getTile(id))
  1160. .hide()
  1161. .show(1000)
  1162. );
  1163. }
  1164.  
  1165. const viewingLatestSketch = window.current == window.max;
  1166. window.max = newMax;
  1167.  
  1168. if(viewingLatestSketch) {
  1169. updateGalleryButtons();
  1170. }
  1171.  
  1172. enableRefresh();
  1173. },
  1174. error: function(req) {
  1175. enableRefresh();
  1176. },
  1177. });
  1178. }
  1179.  
  1180. function gallery_drawData(data) {
  1181. reset();
  1182.  
  1183. var parts = data.split(" ");
  1184. var ox = 0;
  1185. var oy = 0;
  1186. for(var i = 0; i < parts.length; i++) {
  1187. var part = parts[i];
  1188. for(var j = 0; j < part.length; j += 4) {
  1189. var x = dec(part.substr(j, 2));
  1190. var y = dec(part.substr(j+2, 2));
  1191. if(j >= 4) {
  1192. lines.push({
  1193. moveTo: (j == 4),
  1194. x1: ox,
  1195. y1: oy,
  1196. x2: x,
  1197. y2: y,
  1198. });
  1199. }
  1200. ox = x;
  1201. oy = y;
  1202. }
  1203. }
  1204.  
  1205. // dunno what this extra space is for but that's what was
  1206. // on the original client
  1207. window.dat = data.trim() + " ";
  1208.  
  1209. autodrawpos = 0;
  1210. }
  1211.  
  1212. function gallery_reset() {
  1213. let fillColor = 0xFFFFFF;
  1214.  
  1215. // April Fools' 2023 color support
  1216. if(settings.supportApril2023 && window.details) {
  1217. const {id, timestamp} = window.details;
  1218. const aprilFools2023 = (timestamp >= 1680332400) && (timestamp < 1680418800);
  1219. if(aprilFools2023) {
  1220. const color = _getAprilFoolsColor(id);
  1221. const colorInt = parseInt(color.slice(1), 16);
  1222. fillColor = colorInt;
  1223. }
  1224. }
  1225.  
  1226. graphics.clear();
  1227.  
  1228. graphics.beginFill(fillColor);
  1229. graphics.drawRect(0, 0, 800, 600);
  1230. graphics.endFill();
  1231.  
  1232. graphics.lineStyle(3, 0x000000);
  1233. graphics.moveTo(0,0);
  1234.  
  1235. dat = "";
  1236. lines = [];
  1237. cachedCanvasBlob = null;
  1238.  
  1239. if([NOZ_GALLERY_CLIENT, NOZBUNKER_GALLERY_CLIENT].includes(client)) {
  1240. window.autodraw = false;
  1241. }
  1242. else {
  1243. window.autodrawpos = -1;
  1244. }
  1245. }
  1246.  
  1247. function show(id) {
  1248. // show() via page init passes the ID as a string (from URL hash).
  1249. // can't change that since it's fired from an event listener.
  1250. id = parseInt(id);
  1251. if(Number.isNaN(id)) return;
  1252.  
  1253. if(id == 0) return;
  1254. // prevents showing the same sketch again.
  1255. if(id == window.current) return;
  1256.  
  1257. if(client == NOZ_GALLERY_CLIENT) {
  1258. hideTagSuggestions();
  1259. }
  1260.  
  1261. const hashID = `#${id}`
  1262. const historyState = window.history.state;
  1263. const showingFromHash = window.location.hash == hashID;
  1264. if(window.current == null && !showingFromHash) {
  1265. window.history.pushState(historyState, "", hashID);
  1266. }
  1267. else {
  1268. window.history.replaceState(historyState, "", hashID);
  1269. }
  1270.  
  1271. window.current = id;
  1272.  
  1273. // html building
  1274. // TODO: don't rebuild this everytime this function's called
  1275.  
  1276. const {top, left, right} = createGalleryButtons(id);
  1277.  
  1278. let saveParts = [];
  1279. let saveSVGParts = [];
  1280.  
  1281. let saveAnchorStart;
  1282. if(settings.saveAsCanvas) {
  1283. saveAnchorStart = '<a class="save" title="Save (PNG)">'
  1284. } else {
  1285. let sizeParam = settings.sketchSaveResolution * 100;
  1286. let dbParam = window.db != null ? `&db=${window.db}` : "";
  1287. let downloadFn = window.db == null ? `${id}` : `${window.db}#${id}`;
  1288. saveAnchorStart = [
  1289. `<a`,
  1290. ` href="${baseURL}/getIMG.php?format=png${dbParam}&id=${id}&size=${sizeParam}"`,
  1291. ` download="${downloadFn}.png"`,
  1292. ` class="save"`,
  1293. ` title="Save (PNG)"`,
  1294. `>`
  1295. ].join("");
  1296. }
  1297.  
  1298. saveParts.push(
  1299. saveAnchorStart,
  1300. `<img src="save.png" style="width: 25px; height: 25px; position: relative;">`,
  1301. `</a>`,
  1302. );
  1303.  
  1304. if(client == NOZ_GALLERY_CLIENT) {
  1305. saveSVGParts.push(
  1306. '<a class="saveSVG" title="Save (SVG)">',
  1307. `<img src="svg.png" style="width: 25px; height: 25px; position: relative;">`,
  1308. '</a>',
  1309. );
  1310. }
  1311.  
  1312. var saves = [`<div class="saves">`, ...saveParts, ...saveSVGParts, `</div>`].join("");
  1313. var bottom = `<div id="details">...</div>`;
  1314.  
  1315. $("#holder").addClass("active");
  1316. $("#holder").empty();
  1317. $("#holder").append([top, left, sketch, right, bottom, saves]);
  1318. $("#tiles").css({opacity: "75%"});
  1319.  
  1320. $("a.left").click(_navAnchorOverride);
  1321. $("a.right").click(_navAnchorOverride);
  1322. if(settings.saveAsCanvas) {
  1323. $(".save").click(() => saveCanvas());
  1324. }
  1325. if(window.location.hostname == "noz.rip") {
  1326. $(".saveSVG").click(() => saveSVG());
  1327. }
  1328.  
  1329. // clear alerts and other cached properties from the last shown sketch
  1330. lastAlertPromise = null;
  1331.  
  1332. sketch.show();
  1333. sketch.on("click", () => {
  1334. const animating = [NOZ_GALLERY_CLIENT, NOZBUNKER_GALLERY_CLIENT].includes(client)
  1335. ? window.autodraw
  1336. : window.autodrawpos >= 0;
  1337.  
  1338. if(!animating && settings.doReplay) {
  1339. drawData(window.dat);
  1340. } else {
  1341. setData(window.dat);
  1342. }
  1343. });
  1344. reset();
  1345. get(id);
  1346. }
  1347.  
  1348. function hide() {
  1349. $("#tiles").css({opacity: "100%"});
  1350. $("#holder").removeClass("active");
  1351. window.current = null;
  1352. window.details = null;
  1353. reset();
  1354.  
  1355. // Prevent back-forward soft-lock from navigating to gallery.php (w/o hash)
  1356. const firedFromHash = (!window.location.hash || window.location.hash == "#0");
  1357. if(!firedFromHash) {
  1358. window.history.pushState(window.history.state, "", "#0");
  1359. }
  1360.  
  1361. if(client == NOZ_GALLERY_CLIENT) {
  1362. hideTagSuggestions();
  1363. }
  1364. }
  1365.  
  1366. function addToCache(id, details) {
  1367. details.data = details.data.trim();
  1368. cache['#' + id] = details;
  1369. let keys = Object.keys(cache);
  1370. let tail = keys[0];
  1371. if(keys.length > settings.cacheSize) {
  1372. delete cache[tail];
  1373. }
  1374. }
  1375.  
  1376. async function get(id) {
  1377. function success(details) {
  1378. let dat = details.data;
  1379. window.dat = dat;
  1380. window.details = details;
  1381. updateDetails();
  1382.  
  1383. if(dat == "wait") return;
  1384. if(settings.noAnimation) {
  1385. setData(dat);
  1386. } else {
  1387. drawData(dat);
  1388. }
  1389. }
  1390.  
  1391. if(cache.hasOwnProperty("#" + id)) {
  1392. return success(cache["#" + id]);
  1393. }
  1394.  
  1395. $.ajax({
  1396. url: `${baseURL}/get.php?db=${db || ""}&id=${id}&details`,
  1397. dataType: "text",
  1398. success: function(resp) {
  1399. // Despite being a JSON endpoint, "wait" still gets sent as plain
  1400. // text without quotes.
  1401. let details;
  1402. if(resp == "wait") {
  1403. details = {
  1404. id: id,
  1405. data: "wait",
  1406. timestamp: null,
  1407. origin: null,
  1408. };
  1409. } else {
  1410. try {
  1411. details = JSON.parse(resp);
  1412. }
  1413. catch(err) {
  1414. // If we're here, then this is just plain data.
  1415. details = {
  1416. id: id,
  1417. data: resp,
  1418. timestamp: null,
  1419. origin: null,
  1420. };
  1421. }
  1422. }
  1423.  
  1424. if(window.dat.trim() == details.data.trim()) {
  1425. // We already loaded this sketch; don't load it again.
  1426. return;
  1427. }
  1428.  
  1429. addToCache(id, details);
  1430. if(window.current == id) {
  1431. success(details);
  1432. }
  1433. },
  1434. error: function(req) {
  1435. $("#details").html("network error.");
  1436. },
  1437. });
  1438. }
  1439.  
  1440. async function addDateCards(last, size) {
  1441. if(!settings.showDatecards) {
  1442. return;
  1443. }
  1444.  
  1445. for(const [timestamp, datecard, id] of await getDateCards(last, size)) {
  1446. let date = timestamp.toDateString();
  1447.  
  1448. if(datecardDates.has(date)) {
  1449. const datecardID = datecardDates.get(date);
  1450. const datecardNeedsUpdate = id > datecardID;
  1451. if(!datecardNeedsUpdate) {
  1452. continue;
  1453. }
  1454.  
  1455. const a = $(`#tiles a[href='#${datecardID}']`);
  1456. const oldDatecard = a.prev();
  1457. oldDatecard.remove();
  1458. }
  1459.  
  1460. const a = $(`#tiles a[href='#${id}']`);
  1461. if(a.length > 0) {
  1462. a.before(datecard);
  1463. datecardDates.set(date, id);
  1464. }
  1465. }
  1466. }
  1467.  
  1468. async function addMore(n=100) {
  1469. let limit;
  1470. if(client == NOZBUNKER_GALLERY_CLIENT) {
  1471. limit = 1;
  1472. } else {
  1473. const hardLimit = 1;
  1474. const lastPossible = Math.max(hardLimit, window.min);
  1475. limit = lastPossible;
  1476. }
  1477.  
  1478. let newtiles = [];
  1479. let last = window.max - ($("#tiles").children("a").length) + 1;
  1480. let target = Math.max(last - n, limit);
  1481.  
  1482. for(let id = last - 1; id >= target; id--) {
  1483. newtiles.push(getTile(id));
  1484. }
  1485.  
  1486. const footerState = target == limit
  1487. ? FooterState.END_OF_GALLERY
  1488. : FooterState.NORMAL;
  1489. if(footerState == FooterState.END_OF_GALLERY && !(last == target)) {
  1490. const tilesEnd = createGalleryFooter(footerState);
  1491. $("#tilesEnd").replaceWith(tilesEnd);
  1492. }
  1493.  
  1494. $("#tiles").append(newtiles);
  1495.  
  1496. if(client != NOZBUNKER_GALLERY_CLIENT) {
  1497. addDateCards(last - 1, n);
  1498. }
  1499. }
  1500.  
  1501. function addMoreTop(n=100) {
  1502. if(client != NOZBUNKER_GALLERY_CLIENT) {
  1503. return;
  1504. }
  1505.  
  1506. let newtiles = [];
  1507. let last = window.max;
  1508. let target = Math.min(last + n, window.archiveMax);
  1509.  
  1510. for(let id = target; id > window.max; id--) {
  1511. newtiles.push(getTile(id));
  1512. }
  1513.  
  1514. window.max = target;
  1515. $("#tiles").prepend(newtiles);
  1516. $("#status").html(`Showing sketches up to #${target}`);
  1517. if(target == window.archiveMax) {
  1518. $("#loadmoretop").prop("disabled", true);
  1519. }
  1520.  
  1521. const viewingLatestSketch = window.current == last;
  1522. if(viewingLatestSketch) {
  1523. updateGalleryButtons();
  1524. }
  1525. }
  1526.  
  1527. function createBooruFormUI(id) {
  1528. const cookies = document.cookie.split(";");
  1529. const shimUser = cookies.some((c) => c.trim().startsWith("shm_user="));
  1530. const shimSess = cookies.some((c) => c.trim().startsWith("shm_session="));
  1531. const hasBooruCredentials = shimUser && shimSess;
  1532. if(!hasBooruCredentials) {
  1533. return [null, null];
  1534. }
  1535.  
  1536. const sketch = cache["#" + id];
  1537. const unavailable = (sketch.data == "wait" || sketch.data == "wait "); // thanks drawData();
  1538. if(unavailable) {
  1539. return [null, null];
  1540. }
  1541.  
  1542. if(window.db) {
  1543. const warning = $(
  1544. `<button disabled>
  1545. booru doesn't support custom DBs
  1546. </button>
  1547. `);
  1548. return [null, warning];
  1549. }
  1550.  
  1551. const showButton = $("<button>show booru menu</button>");
  1552. const form = $(`
  1553. <form
  1554. id="booruForm"
  1555. target="_blank"
  1556. action="/booru/upload"
  1557. method="POST"
  1558. enctype="multipart/form-data"
  1559. style="display: none;">
  1560. <input type="hidden" name="sketchid" value="${id}">
  1561. <input type="hidden" name="source" value="${currentArchiveURL()}">
  1562. <span id="postStatus"></span>
  1563. <div id="tagsContainer">
  1564. <table id="tagSuggestions" role="listbox"></table>
  1565. <input
  1566. type="text"
  1567. name="tags"
  1568. required
  1569. placeholder="tagme"
  1570. autocomplete="off"
  1571. class="autocomplete_tags">
  1572. </div>
  1573. <div id="booruButtons">
  1574. <!-- Select isn't natively part of the form; post-processing is done to make
  1575. ratings actually get sent. -->
  1576. <select id="rating">
  1577. <option value="?" selected>Unrated</option>
  1578. <option value="s">Safe</option>
  1579. <option value="q">Questionable</option>
  1580. <option value="e">Explicit</option>
  1581. </select>
  1582. <button type="submit">post to booru</button>
  1583. <button type="button" id="hideBooru">hide</button>
  1584. </div>
  1585. </form>
  1586. `);
  1587.  
  1588. // UI and property assignment
  1589.  
  1590. const tagsBar = form.find("input[name='tags']");
  1591. const ratingSelect = form.find("select#rating");
  1592. const postStatus = form.find("#postStatus");
  1593. const sourceField = form.find("input[name='source']");
  1594.  
  1595. postStatus.hide();
  1596. sourceField.prop("disabled", !settings.useArchiveAsBooruSource);
  1597.  
  1598. const booruState = booruStates[id];
  1599. if(booruState) {
  1600. tagsBar.val(booruState.tags);
  1601. ratingSelect.val(booruState.rating);
  1602.  
  1603. const formInputs = form.find(`input, button, select`);
  1604. formInputs.prop("disabled", booruState.uploading);
  1605. }
  1606.  
  1607. const booruPostStatus = settings.samePageBooru && booruState && booruState.booruPostStatus;
  1608. if(booruPostStatus) {
  1609. const otherFormElements = form.children(`*:not(#booruButtons, #postStatus)`);
  1610. const otherButtons = form.find(`#booruButtons *:not(#hideBooru)`);
  1611. otherFormElements.hide();
  1612. otherButtons.hide();
  1613.  
  1614. const postURL = `https://noz.rip/booru/post/view/${booruState.booruPostID}`;
  1615. const postIDHTML = [
  1616. `<a href=${postURL} target="_blank">`,
  1617. `/${booruState.booruPostID}`,
  1618. `</a>`
  1619. ].join("");
  1620.  
  1621. switch(booruState.booruPostStatus) {
  1622. case BooruPostState.POSTED: {
  1623. postStatus.html(`sketch uploaded: ${postIDHTML}`);
  1624. break;
  1625. }
  1626. case BooruPostState.ALREADY_POSTED: {
  1627. postStatus.html(`sketch was already uploaded! ${postIDHTML}`);
  1628. break;
  1629. }
  1630.  
  1631. default: {
  1632. console.error("Unexpected booru post state:", booruState.booruPostStatus);
  1633. }
  1634. case BooruPostState.PARSING_ERROR: {
  1635. postStatus.html(`something went wrong! check console for details.`);
  1636.  
  1637. const submit = form.find("button[type=submit]");
  1638. submit.html("try again");
  1639. submit.show();
  1640. break;
  1641. }
  1642. }
  1643.  
  1644. postStatus.show();
  1645. }
  1646.  
  1647. if(settings.showingBooruMenu) {
  1648. form.show();
  1649. showButton.hide();
  1650. }
  1651.  
  1652. // Autocomplete-related
  1653.  
  1654. const tagSuggestions = form.find("#tagSuggestions");
  1655. tagSuggestions.hide();
  1656. tagsBar.on("input", function() {
  1657. updateTagSuggestions();
  1658. });
  1659. tagsBar.on("keydown", function(event) {
  1660. switch(event.key) {
  1661. case "Tab":
  1662. case "Enter": {
  1663. const dropdownClosed = tagSuggestions.is(":hidden");
  1664. const hasModifiers = (
  1665. event.ctrlKey
  1666. || event.altKey
  1667. || event.metaKey
  1668. || event.shiftKey
  1669. );
  1670. if(dropdownClosed || hasModifiers) {
  1671. return;
  1672. }
  1673.  
  1674. // Prevent form submission or loss of tags bar focus
  1675. event.preventDefault();
  1676.  
  1677. tagSuggestions.hide();
  1678.  
  1679. const currentTag = _getCurrentTag(this);
  1680. const newTag = autocompleteSelected || currentTag;
  1681. addTag(newTag, currentTag);
  1682.  
  1683. break;
  1684. }
  1685.  
  1686. case "ArrowUp":
  1687. case "ArrowDown": {
  1688. const dropdownClosed = tagSuggestions.is(":hidden");
  1689. if(dropdownClosed) {
  1690. return;
  1691. }
  1692.  
  1693. // Prevent text caret from moving to the beginning/end of the tags bar
  1694. event.preventDefault();
  1695.  
  1696. const visibleTagElems = tagSuggestions.children(":not(.tagInfo)");
  1697. const visibleTags = Array.from(visibleTagElems).map(
  1698. (element) => element.querySelector(".tagName").innerHTML
  1699. );
  1700. if(visibleTags.length == 0 || visibleTags.length == 1) {
  1701. return;
  1702. }
  1703.  
  1704. let selectedIndex = visibleTags.findIndex((tag) => tag == autocompleteSelected);
  1705. if(selectedIndex == -1) {
  1706. selectedIndex = 0;
  1707. }
  1708.  
  1709. const dir = event.key == "ArrowUp" ? -1 : 1;
  1710. const ind = selectedIndex;
  1711. const length = visibleTagElems.length;
  1712. const selectedIndexNew = (((ind + dir) % length) + length) % length;
  1713. const selectedNew = visibleTags[selectedIndexNew];
  1714. autocompleteSelect(selectedNew);
  1715.  
  1716. break;
  1717. }
  1718.  
  1719. case "Escape": {
  1720. const dropdownClosed = tagSuggestions.is(":hidden");
  1721. if(dropdownClosed) {
  1722. return;
  1723. }
  1724.  
  1725. // Don't know what this should be preventing specifically, but
  1726. // just in case
  1727. event.preventDefault();
  1728.  
  1729. hideTagSuggestions();
  1730. }
  1731. }
  1732. });
  1733. tagsBar.on("blur", function(event) {
  1734. hideTagSuggestions();
  1735. });
  1736. $(document).on("selectionchange", function() {
  1737. if(!tagsBar.is(":focus")) {
  1738. return;
  1739. }
  1740.  
  1741. const tagsBarElement = tagsBar[0];
  1742.  
  1743. // Don't catch text caret movements from text input.
  1744. const tagsValue = tagsBar.val();
  1745. const tagsValueChanged = lastTagsValue != tagsValue;
  1746. if(tagsValueChanged) {
  1747. const currentTag = _getCurrentTag(tagsBarElement);
  1748. lastTagsValue = tagsValue;
  1749. lastAutocompleteQuery = currentTag;
  1750. return;
  1751. }
  1752.  
  1753. // Text selections should hide #tagSuggestions (we're only catching text caret movement).
  1754. const selectionStart = $("#booruForm input[name=tags]").prop("selectionStart");
  1755. const selectionEnd = $("#booruForm input[name=tags]").prop("selectionEnd");
  1756. const selectingText = selectionStart != selectionEnd;
  1757. if(selectingText) {
  1758. hideTagSuggestions();
  1759. return;
  1760. }
  1761.  
  1762. const currentTag = _getCurrentTag(tagsBarElement);
  1763. const currentTagChanged = lastAutocompleteQuery != currentTag;
  1764. if(currentTagChanged) {
  1765. lastAutocompleteQuery = currentTag;
  1766. updateTagSuggestions();
  1767. }
  1768. });
  1769.  
  1770. // Event listeners
  1771.  
  1772. function toggleForm(showing){
  1773. settings.showingBooruMenu = showing;
  1774. form.toggle(showing);
  1775. showButton.toggle(!showing);
  1776. _saveSettings();
  1777. }
  1778.  
  1779. const hideButton = form.find("#booruButtons #hideBooru");
  1780. showButton.click(() => toggleForm(true));
  1781. hideButton.click(() => toggleForm(false));
  1782.  
  1783. tagsBar.on("change", () => saveBooruChanges(id, form));
  1784. ratingSelect.on("change", () => saveBooruChanges(id, form));
  1785.  
  1786. form.submit(async function(event) {
  1787. const form = $(this);
  1788. const ratingSelect = form.find("select");
  1789. const rating = ratingSelect.val();
  1790.  
  1791. const tagsBar = form.find("input[name='tags']");
  1792. let tags = tagsBar.val();
  1793. let newtags = tags
  1794. .replace(/\s+$/gi, "")
  1795. + (
  1796. tags.match(/\s?rating:./gi)
  1797. ? ""
  1798. : ` rating:${rating}`
  1799. );
  1800. tagsBar.val(newtags.trim());
  1801.  
  1802. if(settings.samePageBooru) {
  1803. event.preventDefault();
  1804.  
  1805. // In the case of retries, clear the existing post status.
  1806. if(booruState && booruState.booruPostStatus) {
  1807. booruState.booruPostStatus = null;
  1808. }
  1809.  
  1810. selfUploadToBooru(id, form);
  1811. }
  1812. });
  1813.  
  1814. return [form, showButton];
  1815. }
  1816.  
  1817. function createPreferencesUI() {
  1818. const button = $("<button>userscript preferences</button>");
  1819. const preferences = $(`<fieldset id="preferences" style="display: none"></fieldset>`);
  1820. preferences.html(`
  1821. <legend>Preferences</legend>
  1822. <fieldset id="preferences-gallery">
  1823. <legend>Gallery</legend>
  1824. <div class="preference">
  1825. <label for="theme">Theme:</label>
  1826. <select id="theme" name="theme">
  1827. <option value="auto" selected>System default</option>
  1828. <option value="dark">Dark</option>
  1829. <option value="light">Light</option>
  1830. </select>
  1831. </div>
  1832. <div class="preference">
  1833. <label for="thumbquality">Thumbnail quality:</label>
  1834. <select id="thumbquality" name="thumbquality">
  1835. <option value="default" selected>Default</option>
  1836. <option value="hq">Downscaled</option>
  1837. <option value="raster">Rasterized</option>
  1838. <option value="oldDefault">Old default</option>
  1839. <option value="awful">What</option>
  1840. </select>
  1841. </div>
  1842. <div class="preference">
  1843. <label for="showstats">Show the "past 5 minutes" stats bar:</label>
  1844. <input type="checkbox" id="showstats">
  1845. </div>
  1846. <div class="preference">
  1847. <label for="showdatecards">Show time cards:</label>
  1848. <input type="checkbox" id="showdatecards">
  1849. <br>
  1850. <i>(cards might not show up for newer sketches due to an API limitation)</i>
  1851. </div>
  1852. </fieldset>
  1853. <fieldset id="preferences-sketches">
  1854. <legend>Sketches</legend>
  1855. <div class="preference">
  1856. <label for="skipanimation">Auto-skip sketch animation:</label>
  1857. <input type="checkbox" id="skipanimation">
  1858. </div>
  1859. <div class="preference">
  1860. <label for="doreplay">Enable sketch animation replay:</label>
  1861. <input type="checkbox" id="doreplay">
  1862. <br>
  1863. <i>(by clicking on the sketch player or pressing Space)</i>
  1864. </div>
  1865. <div class="preference">
  1866. <label for="sketchquality">Sketch quality:</label>
  1867. <select id="sketchquality" name="sketchquality">
  1868. <option value="default" selected>No spikes (default)</option>
  1869. <option value="spiky">Spiky (old)</option>
  1870. </select>
  1871. </div>
  1872. <div class="preference">
  1873. <label for="sketchsaveresolution">Sketch save resolution:</label>
  1874. <select id="sketchsaveresolution" name="sketchsaveresolution">
  1875. <option value="1" selected>1x</option>
  1876. <option value="2" title="haha, kinda like the artist">2x</option>
  1877. <!-- There's an artist in GaryC that usually goes by "2x". -->
  1878. <option value="4">4x</option>
  1879. </select>
  1880. <br>
  1881. <i>(only works for sketch player quality saves)</i>
  1882. </div>
  1883. <div class="preference">
  1884. <label for="saveascanvas">Save sketches in sketch player quality:</label>
  1885. <input type="checkbox" id="saveascanvas">
  1886. <br>
  1887. <i>(useful if you don't like how screentones look in saves)</i>
  1888. </div>
  1889. </fieldset>
  1890. <fieldset id="preferences-advanced">
  1891. <legend>Advanced</legend>
  1892. <div class="preference">
  1893. <label for="cachesize">Sketch cache size:</label>
  1894. <input type="number" id="cachesize" min="0">
  1895. </div>
  1896. <div class="preference">
  1897. <label for="relativetimestamps">Show sketch timestamps as relative:</label>
  1898. <input type="checkbox" id="relativetimestamps">
  1899. </div>
  1900. <div class="preference">
  1901. <label for="supportapril2023">Add color to sketches from April Fools' 2023:</label>
  1902. <input type="checkbox" id="supportapril2023">
  1903. </div>
  1904. </fieldset>
  1905. `);
  1906.  
  1907. button.click(() => preferences.slideToggle(200));
  1908.  
  1909. preferences.find("#theme").val(settings.theme);
  1910. preferences.find("#cachesize").val(settings.cacheSize);
  1911. preferences.find("#skipanimation").prop("checked", settings.noAnimation);
  1912. preferences.find("#doreplay").prop("checked", settings.doReplay);
  1913. preferences.find("#thumbquality").val(settings.thumbQuality);
  1914. preferences.find("#sketchquality").val(settings.sketchQuality);
  1915. preferences.find("#relativetimestamps").prop("checked", settings.relativeTimestamps);
  1916. preferences.find("#showdatecards").prop("checked", settings.showDatecards);
  1917. preferences.find("#saveascanvas").prop("checked", settings.saveAsCanvas);
  1918. preferences.find("#sketchsaveresolution").val(settings.sketchSaveResolution);
  1919. preferences.find("#showstats").prop("checked", settings.showStats);
  1920. preferences.find("#supportapril2023").prop("checked", settings.supportApril2023);
  1921.  
  1922. preferences.find("#cachesize").change(function(e) {
  1923. settings.cacheSize = e.target.value;
  1924. _saveSettings();
  1925. });
  1926. preferences.find("#skipanimation").change(function(e) {
  1927. settings.noAnimation = e.target.checked;
  1928. _saveSettings();
  1929. });
  1930. preferences.find("#doreplay").change(function(e) {
  1931. settings.doReplay = e.target.checked;
  1932. _saveSettings();
  1933. });
  1934. preferences.find("#theme").change(function(e) {
  1935. settings.theme = e.target.value;
  1936. _updateTheme();
  1937. _saveSettings();
  1938. });
  1939. preferences.find("#thumbquality").change(function(e) {
  1940. settings.thumbQuality = e.target.value;
  1941. _saveSettings();
  1942.  
  1943. let size = _getThumbSize(settings.thumbQuality);
  1944. $("a > img").each(function(ind, img) {
  1945. img.src = img.src.replace(
  1946. /size=[\d.]+/,
  1947. `size=${size}`
  1948. );
  1949. });
  1950. });
  1951. preferences.find("#sketchquality").change(function(e) {
  1952. settings.sketchQuality = e.target.value;
  1953. _updateSketchQuality(settings.sketchQuality);
  1954. _saveSettings();
  1955. });
  1956. preferences.find("#relativetimestamps").change(function(e) {
  1957. settings.relativeTimestamps = e.target.checked;
  1958. _saveSettings();
  1959. });
  1960. preferences.find("#showdatecards").change(function(e) {
  1961. settings.showDatecards = e.target.checked;
  1962. _saveSettings();
  1963.  
  1964. if(e.target.checked) {
  1965. addDateCards(window.max, $("#tiles").children().length - 1);
  1966. } else {
  1967. $(".datecard").remove();
  1968. datecardDates.clear();
  1969. }
  1970. });
  1971. preferences.find("#saveascanvas").change(function(e) {
  1972. settings.saveAsCanvas = e.target.checked;
  1973. _saveSettings();
  1974. });
  1975. preferences.find("#sketchsaveresolution").change(function(e) {
  1976. settings.sketchSaveResolution = parseInt(e.target.value);
  1977. _saveSettings();
  1978. });
  1979. preferences.find("#showstats").change(function(e) {
  1980. settings.showStats = e.target.checked;
  1981. _saveSettings();
  1982.  
  1983. $("#stats").toggle(settings.showStats);
  1984. });
  1985. preferences.find("#supportapril2023").change(function(e) {
  1986. settings.supportApril2023 = e.target.checked;
  1987. _saveSettings();
  1988. });
  1989.  
  1990. switch(client) {
  1991. case NOZ_GALLERY_CLIENT: {
  1992. applyNozPreferences(preferences);
  1993. break;
  1994. }
  1995. case NOZBUNKER_GALLERY_CLIENT: {
  1996. applyBunkerPreferences(preferences);
  1997. break;
  1998. }
  1999. }
  2000.  
  2001. return [button, preferences];
  2002. }
  2003.  
  2004. function applyNozPreferences(preferences) {
  2005. const preferencesSketches = preferences.find("#preferences-sketches");
  2006. const preferencesBooru = $(`
  2007. <fieldset id="preferences-booru">
  2008. <legend>Booru</legend>
  2009. <div class="preference">
  2010. <label for="samepagebooru">Post to booru without opening a new tab:</label>
  2011. <input type="checkbox" id="samepagebooru">
  2012. </div>
  2013. <div class="preference">
  2014. <label for="archiveassource">Add archive link as booru source:</label>
  2015. <input type="checkbox" id="archiveassource">
  2016. </div>
  2017. </fieldset>
  2018. `);
  2019. preferencesSketches.after(preferencesBooru);
  2020.  
  2021. preferences.find("#samepagebooru").prop("checked", settings.samePageBooru);
  2022. preferences.find("#archiveassource").prop("checked", settings.useArchiveAsBooruSource);
  2023.  
  2024. preferences.find("#samepagebooru").change(function(e) {
  2025. settings.samePageBooru = e.target.checked;
  2026.  
  2027. // Updates the booru menu
  2028. if(window.current != null) {
  2029. updateDetails();
  2030. }
  2031.  
  2032. _saveSettings();
  2033. });
  2034. preferences.find("#archiveassource").change(function(e) {
  2035. settings.useArchiveAsBooruSource = e.target.checked;
  2036. _saveSettings();
  2037. });
  2038. }
  2039.  
  2040. function applyBunkerPreferences(preferences) {
  2041. const toremove = [
  2042. preferences.find("#thumbquality"),
  2043. preferences.find("#showdatecards"),
  2044. preferences.find("#showstats"),
  2045. ];
  2046. for(const pref of toremove) {
  2047. pref.parent().remove();
  2048. }
  2049. }
  2050.  
  2051. function createGalleryFooter(footerState=FooterState.NORMAL) {
  2052. const tilesEnd = $(`<footer id="tilesEnd"></footer>`);
  2053.  
  2054. switch(footerState) {
  2055. case FooterState.END_OF_GALLERY: {
  2056. tilesEnd.html(`
  2057. and then there were none.
  2058. <button>back to top</button>
  2059. `);
  2060. tilesEnd.find("button").on("click", () => document.documentElement.scrollIntoView());
  2061. break;
  2062. }
  2063.  
  2064. case FooterState.NORMAL:
  2065. default: {
  2066. tilesEnd.html(`
  2067. <button>load more</button>
  2068. `);
  2069. tilesEnd.find("button").on("click", () => addMore(100));
  2070. break;
  2071. }
  2072. }
  2073.  
  2074. return tilesEnd;
  2075. }
  2076.  
  2077. function createLoadMoreTopButton() {
  2078. const button = $(`<button id="loadmoretop">load more</button>`);
  2079.  
  2080. button.click(() => addMoreTop(100));
  2081. return button;
  2082. }
  2083.  
  2084. function createBunkerStatus() {
  2085. const status = $(`<span id="status"></span>`);
  2086. return status;
  2087. }
  2088.  
  2089. async function personalKeybinds(e) {
  2090. if(window.current == null) {
  2091. return;
  2092. }
  2093. if(document.activeElement.nodeName == "INPUT") {
  2094. return;
  2095. }
  2096.  
  2097. switch(e.key.toLowerCase()) {
  2098. case " ": {
  2099. // space -- skip/replay animation
  2100. if(!(e.ctrlKey || e.altKey || e.metaKey || e.shiftKey)) {
  2101. e.preventDefault();
  2102. sketch.click();
  2103. }
  2104. break;
  2105. }
  2106. case "c": {
  2107. const selection = document.getSelection();
  2108. if(selection) {
  2109. break;
  2110. }
  2111.  
  2112. // ctrl+C -- copying URL to clipboard
  2113. if(e.ctrlKey && !(e.altKey || e.metaKey || e.shiftKey)) {
  2114. e.preventDefault();
  2115.  
  2116. if(!navigator.clipboard) {
  2117. await detailsAlert("no clipboard permissions!");
  2118. return false;
  2119. }
  2120.  
  2121. await navigator.clipboard.writeText(currentURL());
  2122. await detailsAlert("copied url");
  2123. }
  2124.  
  2125. // ctrl+shift+C -- copying canvas image to clipboard
  2126. if(e.ctrlKey && e.shiftKey && !(e.altKey || e.metaKey)) {
  2127. e.preventDefault();
  2128.  
  2129. if(!window.ClipboardItem) {
  2130. await detailsAlert("no permission to copy canvas");
  2131. return false;
  2132. }
  2133. if(!navigator.clipboard) {
  2134. await detailsAlert("no clipboard permissions!");
  2135. return false;
  2136. }
  2137.  
  2138. let blob = cachedCanvasBlob || await new Promise((resolve) => {
  2139. document.querySelector("#sketch").toBlob(blob => resolve(blob))
  2140. });
  2141.  
  2142. const animating = [NOZ_GALLERY_CLIENT, NOZBUNKER_GALLERY_CLIENT].includes(client)
  2143. ? window.autodraw
  2144. : window.autodrawpos >= 0;
  2145. if(!animating) {
  2146. cachedCanvasBlob = blob;
  2147. }
  2148.  
  2149. try {
  2150. await navigator.clipboard.write([new ClipboardItem({[blob.type]: blob})]);
  2151. }
  2152. catch (e) {
  2153. // .write will raise a DOMException if the document lost focus.
  2154. // that should be the only user-made error to expect during the copying anyway.
  2155. await detailsAlert("failed to copy canvas. try again?")
  2156. throw e;
  2157. }
  2158.  
  2159. await detailsAlert("copied canvas");
  2160. }
  2161. break;
  2162. }
  2163. case "s": {
  2164. // ctrl+S -- downloads/saves a sketch
  2165. if(e.ctrlKey && !(e.altKey || e.metaKey || e.shiftKey)) {
  2166. e.preventDefault();
  2167. $(".save").click();
  2168. }
  2169. }
  2170. }
  2171. }
  2172.  
  2173.  
  2174. function _gallery_commonStyles() {
  2175. GM_addStyle(`
  2176. body {
  2177. margin: 10px 10px;
  2178. }
  2179.  
  2180. input[type=text] {
  2181. margin: 0px 4px;
  2182. }
  2183.  
  2184. input[type=submit], button {
  2185. margin: 5px 4px;
  2186. }
  2187.  
  2188. #stats,
  2189. #status {
  2190. display: inline-block;
  2191. font-family: "Helvetica", "Arial", sans-serif;
  2192. margin: 0px 4px;
  2193. }
  2194.  
  2195. #status {
  2196. font-style: italic;
  2197. }
  2198.  
  2199. canvas {
  2200. /* prevent canvas from showing up for a split second on page boot */
  2201. display: none;
  2202. /* re-add garyc.me border on noz.rip */
  2203. border: 1px black solid;
  2204. }
  2205.  
  2206. #tiles {
  2207. font-family: monospace;
  2208. }
  2209.  
  2210. #details {
  2211. box-sizing: border-box;
  2212. padding: 10px 60px;
  2213. width: 100%;
  2214. height: 100%;
  2215. overflow: auto;
  2216.  
  2217. text-align: left;
  2218. font-size: 18px;
  2219. font-family: monospace;
  2220. }
  2221.  
  2222. #details .id {
  2223. font-weight: bold;
  2224. }
  2225.  
  2226. #details .extra {
  2227. opacity: 80%;
  2228. font-style: italic;
  2229. }
  2230.  
  2231. #details .extra span[title]:hover {
  2232. text-decoration: underline dotted;
  2233. }
  2234.  
  2235. #holder {
  2236. display: none;
  2237. z-index: 1;
  2238. background-color: white;
  2239. box-shadow: 0px 0px 10px #00000077;
  2240. position: fixed;
  2241.  
  2242. /* fixes garyc.me's centering management */
  2243. position: fixed;
  2244. top: 50%;
  2245. left: 50%;
  2246. transform: translate(-50%, -50%);
  2247. }
  2248.  
  2249. #holder img {
  2250. user-select: none;
  2251. }
  2252.  
  2253. #tilesEnd {
  2254. padding: 10px;
  2255. text-align: center;
  2256. font-family: monospace;
  2257. }
  2258.  
  2259. /* preferences */
  2260. #preferences {
  2261. max-width: 350px;
  2262. margin: 5px; /* match that of #tiles */
  2263. font-family: monospace;
  2264. }
  2265. #preferences fieldset {
  2266. border-left: none;
  2267. border-right: none;
  2268. border-bottom: none;
  2269. }
  2270. #preferences .preference {
  2271. padding: 4px;
  2272. }
  2273. #preferences .preference i {
  2274. opacity: 50%;
  2275. }
  2276.  
  2277. /* grid styles for holder */
  2278. #holder.active {
  2279. display: grid;
  2280. }
  2281. #holder {
  2282. width: auto;
  2283. justify-items: center;
  2284. padding: 0px 2px;
  2285. grid-template-columns: 100px 808px 100px;
  2286. grid-template-rows: 100px 577px 25px 100px;
  2287. grid-template-areas:
  2288. "x x x"
  2289. "l c r"
  2290. "l c s"
  2291. "d d d";
  2292. }
  2293. #holder > .top {grid-area: x;}
  2294. #holder > .left {grid-area: l;}
  2295. #holder > canvas {grid-area: c;}
  2296. #holder > .right {
  2297. grid-area: r;
  2298. /* prevent overflowing to .saves */
  2299. overflow: hidden;
  2300. height: 100%;
  2301. }
  2302. #holder > #details {grid-area: d;}
  2303. #holder > .saves {
  2304. box-sizing: border-box;
  2305. width: 100%;
  2306. padding-left: 5px;
  2307. grid-area: s;
  2308. justify-self: start;
  2309. }
  2310.  
  2311. /* datecards */
  2312. .datecard {
  2313. display: inline-block;
  2314. vertical-align: middle;
  2315. width: 160px;
  2316. height: 120px;
  2317.  
  2318. box-sizing: border-box;
  2319. border: 2px solid #ccd;
  2320. margin: 5px;
  2321. }
  2322. .datecard div {
  2323. display: flex;
  2324. width: 100%;
  2325. height: 100%;
  2326. padding: 10px;
  2327.  
  2328. align-items: center;
  2329. justify-content: center;
  2330. box-sizing: border-box;
  2331. }
  2332. a img {
  2333. /* aligns sketch thumbnails with the cards */
  2334. vertical-align: middle;
  2335. }
  2336.  
  2337. /* just some stylistic choices */
  2338. #tiles {
  2339. transition: opacity 0.2s ease;
  2340. }
  2341. #holder a {
  2342. cursor: pointer;
  2343. }
  2344. #holder img:hover {
  2345. opacity: 80%;
  2346. }
  2347. `);
  2348. }
  2349.  
  2350. function _gallery_commonNozStyles() {
  2351. GM_addStyle(`
  2352. /* #holder svg styles */
  2353.  
  2354. #holder svg {
  2355. width: 100%;
  2356. height: 100%;
  2357. padding: 10px;
  2358. box-sizing: border-box;
  2359. }
  2360. #holder .top,
  2361. #holder .left,
  2362. #holder .right {
  2363. height: 100%;
  2364. width: 100%;
  2365. }
  2366.  
  2367. /* alignment of close button */
  2368. #holder .top {
  2369. display: flex;
  2370. flex-direction: column-reverse;
  2371. align-items: flex-end;
  2372.  
  2373. padding-right: 50px;
  2374. box-sizing: border-box;
  2375. }
  2376. #holder .top svg {
  2377. height: 60px;
  2378. width: 60px;
  2379. }
  2380.  
  2381. /* stylistic choices */
  2382.  
  2383. #holder .top:hover,
  2384. #holder .left:hover,
  2385. #holder .right:hover {
  2386. opacity: 80%;
  2387. }
  2388. `);
  2389. }
  2390.  
  2391. function _gallery_commonOverrides() {
  2392. document.addEventListener("keydown", personalKeybinds.bind(this));
  2393.  
  2394. // On garyc.me, this uses the scrolling behavior the site used to have;
  2395. // i.e. thumbnails will only get added at the *bottom* of the page.
  2396. $(window).off("scroll");
  2397. $(window).on("scroll", function(e) {
  2398. let pageHeight = document.documentElement.scrollHeight;
  2399. let pageScroll = window.scrollY + window.innerHeight;
  2400. let bottom = pageHeight - pageScroll <= 1; // == 1 is for garyc.me
  2401. if(bottom) {
  2402. addMore(100);
  2403. }
  2404. });
  2405.  
  2406. $(document).off("keydown");
  2407. $(document).on("keydown", function(e) {
  2408. if(document.activeElement.nodeName == "INPUT") {
  2409. return;
  2410. }
  2411.  
  2412. switch(e.key) {
  2413. case "Escape": {
  2414. if(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;
  2415. if(window.current != null) {
  2416. hide();
  2417. // Prevent abortion of page load when the viewer is still open.
  2418. // The user only wants to exit the viewer in this case.
  2419. e.preventDefault();
  2420. }
  2421. }
  2422.  
  2423. // ArrowLeft and ArrowRight no longer
  2424. // update window.current.
  2425.  
  2426. case "ArrowLeft": {
  2427. if(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;
  2428. if(window.current == null) return;
  2429. if(window.current >= window.max) return;
  2430. if(window.current < window.min) {
  2431. show(window.min);
  2432. return false;
  2433. }
  2434. show(window.current + 1);
  2435. return false;
  2436. }
  2437.  
  2438. case "ArrowRight": {
  2439. if(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;
  2440. if(window.current == null || window.current > window.max) {
  2441. show(window.max);
  2442. return false;
  2443. }
  2444. if(window.current <= window.min) return;
  2445. show(window.current - 1);
  2446. return false;
  2447. }
  2448. }
  2449. });
  2450.  
  2451. window.addEventListener("hashchange", function(e) {
  2452. if(!window.location.hash) {
  2453. hide();
  2454. }
  2455.  
  2456. let id = parseInt(window.location.hash.slice(1));
  2457. // prevent show() from firing again
  2458. if(id == window.current) return;
  2459. if(id == 0) {
  2460. hide();
  2461. } else {
  2462. show(id);
  2463. }
  2464. });
  2465. }
  2466.  
  2467. function _gallery_commonDOMOverrides() {
  2468. // remove text nodes that causes buttons to be spaced out.
  2469. // the spacing will get re-added as css.
  2470. let text_nodes = Array
  2471. .from(document.body.childNodes)
  2472. .filter(e => e.nodeType == Node.TEXT_NODE);
  2473. $(text_nodes).remove();
  2474.  
  2475. const [button, preferences] = createPreferencesUI();
  2476. $("input[type=submit]:last-of-type").after(button);
  2477. $("#tiles").before(preferences);
  2478.  
  2479. const tilesEnd = createGalleryFooter();
  2480. $("#tiles").after(tilesEnd);
  2481. }
  2482.  
  2483.  
  2484. if(window.location.pathname == "/sketch/gallery.php" && window.location.hostname == "garyc.me") {
  2485. _gallery_commonStyles();
  2486.  
  2487. window.update = gallery_update;
  2488. window.refresh = refresh;
  2489. setInterval(window.update, 1000/30);
  2490. setInterval(window.refresh, 15000);
  2491.  
  2492. window.reset = gallery_reset;
  2493. window.drawData = gallery_drawData;
  2494. window.show = show;
  2495. window.hide = hide;
  2496. window.get = get;
  2497. window.addMore = addMore;
  2498.  
  2499. _gallery_commonOverrides();
  2500.  
  2501. // garyc.me doesn't even HAVE a <body> tag;
  2502. // DOM manipulation can only happen after DOMContentLoaded.
  2503.  
  2504. document.addEventListener("DOMContentLoaded", function() {
  2505. window.current = null;
  2506.  
  2507. _gallery_commonDOMOverrides();
  2508.  
  2509. // clear the script tag and the extra newline that causes
  2510. // misalignment of new sketches
  2511. document.getElementById("tiles").innerHTML = "";
  2512. // add a little init text for the stats
  2513. document.getElementById("stats").innerHTML = "...";
  2514.  
  2515. // remove inline css for the style overrides
  2516. $("#holder").css({
  2517. top: "",
  2518. left: "",
  2519. margin: "",
  2520. position: "",
  2521. width: "",
  2522. });
  2523. $("#sketch").css({
  2524. border: "",
  2525. });
  2526. });
  2527.  
  2528. // these are assigned on another `.ready` event;
  2529. // overwrite them on another one
  2530. $(document).ready(function() {
  2531. $("#sketch").attr({
  2532. tabindex: "0",
  2533. // fix canvas not being 798x598
  2534. width: "800px",
  2535. height: "600px",
  2536. });
  2537. $("#sketch").css({
  2538. // constrain actual size to 800x600 in case the canvas gets scaled up
  2539. width: "800px",
  2540. height: "600px",
  2541. });
  2542.  
  2543. _updateSketchQuality(settings.sketchQuality);
  2544. });
  2545. }
  2546.  
  2547. if(window.location.pathname == "/sketch/gallery.php" && window.location.hostname == "noz.rip") {
  2548. _gallery_commonStyles();
  2549. _gallery_commonNozStyles();
  2550. GM_addStyle(`
  2551. /* noz.rip-specific #details styles */
  2552.  
  2553. #details {
  2554. display: flex;
  2555. gap: 30px;
  2556.  
  2557. height: min-content;
  2558. max-height: 100%;
  2559. }
  2560.  
  2561. #details #details-left {
  2562. flex: 0 1 auto;
  2563. overflow: auto;
  2564. }
  2565.  
  2566. #details #details-right {
  2567. flex: 1 0 auto;
  2568.  
  2569. display: flex;
  2570. flex-direction: column;
  2571. align-items: flex-end;
  2572. justify-content: flex-end;
  2573. }
  2574.  
  2575. #details form {
  2576. width: 100%;
  2577. display: flex;
  2578. flex-direction: column;
  2579. align-items: flex-end;
  2580. text-align: right;
  2581. }
  2582.  
  2583. #details form input[type="text"] {
  2584. min-width: min-content;
  2585. width: 100%;
  2586. max-width: 400px;
  2587. height: 2em;
  2588. padding: 0px 5px;
  2589. box-sizing: border-box;
  2590. }
  2591.  
  2592. /* tag autocomplete styles */
  2593.  
  2594. #details {
  2595. overflow: visible;
  2596. }
  2597.  
  2598. #tagsContainer {
  2599. width: 100%;
  2600. max-width: 400px;
  2601.  
  2602. /* Position #tagSuggestions' parent so #tagSuggestions can be
  2603. absolutely positioned to it */
  2604. position: relative;
  2605. }
  2606.  
  2607. #tagSuggestions {
  2608. position: absolute;
  2609. right: calc(100% + 10px);
  2610. bottom: 0px;
  2611.  
  2612. display: block;
  2613. user-select: none;
  2614. z-index: var(--z-index-dropdown);
  2615. background-color: var(--background-tag-suggestions);
  2616. box-shadow: 0px 0px 10px #00000077;
  2617. margin: 0;
  2618. padding: 10px;
  2619. width: max-content;
  2620.  
  2621. /* Ditch default border spacing */
  2622. border-spacing: 0;
  2623. }
  2624.  
  2625. #tagSuggestions td {
  2626. /* Alternative to border-spacing in #tagSuggestions where the <tr>
  2627. background would actually fill in the spacing gaps */
  2628. padding: 0 5px;
  2629. }
  2630.  
  2631. #tagSuggestions tr[aria-selected="true"] {
  2632. background-color: var(--background-tag-suggestions-selected);
  2633. text-decoration: underline;
  2634. }
  2635.  
  2636. #tagSuggestions tr.tagInfo {
  2637. text-align: center;
  2638. font-style: italic;
  2639. }
  2640. #tagSuggestions tr.tagInfo:not(:only-child) td {
  2641. padding: 5px;
  2642. }
  2643. #tagSuggestions .tagName {
  2644. text-align: right;
  2645. }
  2646. #tagSuggestions .tagCount {
  2647. text-align: left;
  2648. font-style: italic;
  2649. opacity: 50%;
  2650. }
  2651. `);
  2652.  
  2653. // noz.rip has the JS code AFTER the <body> tag.
  2654. // Same case with the jQuery import, so DOM manipulation
  2655. // can only be executed after DOMContentLoaded.
  2656. // One of these days, I'm just gonna snap.
  2657.  
  2658. document.addEventListener("DOMContentLoaded", function() {
  2659. _purgeIntervals();
  2660.  
  2661. // noz.rip doesn't have a stats bar but this works surprisingly fine.
  2662. window.refresh = refresh;
  2663. setInterval(window.refresh, 15000);
  2664.  
  2665. window.reset = gallery_reset;
  2666. window.show = show;
  2667. window.hide = hide;
  2668. window.get = get;
  2669. window.addMore = addMore;
  2670.  
  2671. _gallery_commonOverrides();
  2672.  
  2673. window.max = null;
  2674. window.min = null;
  2675. window.current = null;
  2676. // turn window.max into a Number;
  2677. // the window.max fetched via $.ajax() is saved as a string.
  2678. window.refresh();
  2679.  
  2680. // use the new show();
  2681. // setupOverlay override cancels the old show() from being used
  2682. window.setupOverlay = (() => void 0);
  2683. let hash = window.location.hash.slice(1);
  2684. if(hash) {
  2685. window.show(hash);
  2686. }
  2687.  
  2688. _gallery_commonDOMOverrides();
  2689.  
  2690. const stats = $("#stats");
  2691. const statsExists = stats.length >= 1;
  2692. if(!statsExists) {
  2693. const preferencesButton = $("button + a").prev();
  2694. const stats = createStats();
  2695. stats.toggle(settings.showStats);
  2696. preferencesButton.after(stats);
  2697. }
  2698.  
  2699. // remove inline css for the style overrides
  2700. $("#holder").css({
  2701. position: "",
  2702. width: "",
  2703. height: "",
  2704. backgroundColor: "",
  2705. display: "",
  2706. });
  2707. $("#sketch").css({
  2708. // remove white background of the canvas
  2709. background: "",
  2710. // remove absolute positioning of the canvas
  2711. position: "",
  2712. top: "",
  2713. left: "",
  2714. transform: "",
  2715. // replace box-shadow with border; caused dark mode to show
  2716. // white edges around the canvas
  2717. boxShadow: "",
  2718. // constrain actual size to 800x600 in case the canvas gets scaled up
  2719. width: "800px",
  2720. height: "600px",
  2721. });
  2722.  
  2723. $("#sketch").attr({
  2724. tabindex: "0",
  2725. width: "800px",
  2726. height: "600px",
  2727. });
  2728.  
  2729. _updateSketchQuality(settings.sketchQuality);
  2730. });
  2731. }
  2732.  
  2733. if(window.location.pathname == "/sketch_bunker/gallery.php" && window.location.hostname == "noz.rip") {
  2734. _gallery_commonStyles();
  2735. _gallery_commonNozStyles();
  2736.  
  2737. // Use .customMax instead of noz.rip's .custom_max for the sake of naming consistency.
  2738. // I advise contributors to use this one too for the same reason.
  2739. window.customMax = null;
  2740. const customMax = new URLSearchParams(window.location.search).get("maxid");
  2741. const cm = parseInt(customMax);
  2742. if(!Number.isNaN(cm)) {
  2743. window.customMax = cm;
  2744. }
  2745.  
  2746. // Hide <tiles> for this site's addMore() monkeypatch
  2747. const style = document.createElement("style");
  2748. style.innerHTML = (`
  2749. #tiles {
  2750. display: none;
  2751. }
  2752. `);
  2753. document.head.appendChild(style);
  2754.  
  2755. // noz.rip/sketch_bunker/ ALSO has the body after the JS tag.
  2756. // There will be bloodshed.
  2757.  
  2758. document.addEventListener("DOMContentLoaded", function() {
  2759. _purgeIntervals();
  2760.  
  2761. window.refresh = nozBunker_refresh;
  2762. setInterval(window.refresh, 15000);
  2763.  
  2764. window.reset = gallery_reset;
  2765. window.show = show;
  2766. window.hide = hide;
  2767. window.get = get;
  2768. window.addMore = addMore;
  2769.  
  2770. _gallery_commonOverrides();
  2771.  
  2772. window.min = 1;
  2773. window.max = window.customMax || window.max;
  2774. window.archiveMax = null;
  2775. window.current = null;
  2776.  
  2777. for(const script of $("#tiles ~ script")) {
  2778. const maxMatch = $(script).html().match(/max=(?<max>\d+)/);
  2779. if(maxMatch) {
  2780. window.archiveMax = parseInt(maxMatch.groups.max);
  2781. break;
  2782. }
  2783. }
  2784.  
  2785. // use the new show();
  2786. // setupOverlay override cancels the old show() from being used
  2787. window.setupOverlay = (() => void 0);
  2788. let hash = window.location.hash.slice(1);
  2789. if(hash) {
  2790. window.show(hash);
  2791. }
  2792.  
  2793. // DOM manipulation
  2794.  
  2795. _gallery_commonDOMOverrides();
  2796.  
  2797. // addMore() can't be monkeypatched in time before it gets first fired.
  2798. // Guess we have to do some dirty work "behind-the-scenes".
  2799. $("#tiles").empty();
  2800. style.remove();
  2801. addMore();
  2802.  
  2803. if(window.archiveMax && (window.archiveMax > window.max)) {
  2804. const loadmoreTop = createLoadMoreTopButton();
  2805. const status = createBunkerStatus();
  2806.  
  2807. const preferencesButton = $("button + #holder").prev();
  2808. preferencesButton.after(status);
  2809. $("#refresh").after(loadmoreTop);
  2810. $("#refresh").hide();
  2811.  
  2812. status.html(`Showing sketches up to #${window.max}`);
  2813. }
  2814.  
  2815. // remove inline css for the style overrides
  2816. $("#holder").css({
  2817. position: "",
  2818. width: "",
  2819. height: "",
  2820. backgroundColor: "",
  2821. display: "",
  2822. });
  2823.  
  2824. $("#sketch").css({
  2825. // remove absolute positioning of the canvas
  2826. position: "",
  2827. top: "",
  2828. left: "",
  2829. transform: "",
  2830. // replace box-shadow with border; caused dark mode to show
  2831. // white edges around the canvas
  2832. boxShadow: "",
  2833. // constrain actual size to 800x600 in case the canvas gets scaled up
  2834. width: "800px",
  2835. height: "600px",
  2836. });
  2837.  
  2838. $("#sketch").attr({
  2839. tabindex: "0",
  2840. width: "800px",
  2841. height: "600px",
  2842. });
  2843.  
  2844. $("#refresh").prop("disabled", !!window.customMax);
  2845.  
  2846. _updateSketchQuality(settings.sketchQuality);
  2847. });
  2848. }
  2849.  
  2850.  
  2851. /* /sketch/ */
  2852.  
  2853. const SwapState = {
  2854. IDLE: 0,
  2855. SWAPPING: 1,
  2856. PEEKING_FROM_SWAP: 2,
  2857. PEEKING: 3,
  2858. WAITING_PEEK: 4,
  2859. DONE_FROM_SWAP: 5,
  2860. DONE: 6,
  2861. }
  2862.  
  2863. function _setProgress(n) {
  2864. n = Math.min(Math.max(n, 0), 3);
  2865. let width = Math.round(n / 3 * 100);
  2866. $("#progress").attr({"aria-valuenow": n});
  2867. $("#progressBar").width(`${width}%`);
  2868. }
  2869.  
  2870. function updateUI(state) {
  2871. let dat;
  2872. if(window.location.hostname == "noz.rip") {
  2873. dat = window.arrdat.join(" ");
  2874. } else {
  2875. dat = window.dat;
  2876. }
  2877.  
  2878. const ink = Math.floor(dat.length / window.limit * 100);
  2879.  
  2880. switch(state) {
  2881. case SwapState.IDLE: {
  2882. $("#ink").html(`Ink used: ${ink}%`);
  2883. $("#reset").prop("disabled", false);
  2884. $("#undo").prop("disabled", dat.length == 0);
  2885. $("#swap").prop("disabled", ink < 1);
  2886. $("#peek").prop("disabled", ink >= 1);
  2887. $("#swap").val("swap");
  2888. _setProgress(0);
  2889. break;
  2890. }
  2891. case SwapState.SWAPPING: {
  2892. $("#ink").html(`Ink used: ${ink}%`);
  2893. $("#reset").prop("disabled", true);
  2894. $("#undo").prop("disabled", true);
  2895. $("#swap").prop("disabled", true);
  2896. $("#peek").prop("disabled", true);
  2897. $("#swap").val("swapping...");
  2898. _setProgress(1);
  2899. break;
  2900. }
  2901. case SwapState.PEEKING_FROM_SWAP: {
  2902. $("#swap").val("swapping...");
  2903. }
  2904. case SwapState.PEEKING_FROM_SWAP:
  2905. case SwapState.PEEKING: {
  2906. $("#ink").html(`Ink used: ${ink}%`);
  2907. $("#reset").prop("disabled", true);
  2908. $("#undo").prop("disabled", true);
  2909. $("#swap").prop("disabled", true);
  2910. $("#peek").prop("disabled", true);
  2911. _setProgress(2);
  2912. break;
  2913. }
  2914. case SwapState.WAITING_PEEK: {
  2915. $("#ink").html(`Ink used: ${ink}%`);
  2916. $("#reset").prop("disabled", true);
  2917. $("#undo").prop("disabled", true);
  2918. $("#swap").prop("disabled", true);
  2919. $("#peek").prop("disabled", true);
  2920. $("#swap").val("waiting for other sketch to be drawn...");
  2921. _setProgress(2);
  2922. break;
  2923. }
  2924. case SwapState.DONE_FROM_SWAP: {
  2925. $("#swap").val("swapped!");
  2926. }
  2927. case SwapState.DONE_FROM_SWAP:
  2928. case SwapState.DONE: {
  2929. $("#ink").html(`Ink used: ${ink}%`);
  2930. $("#reset").prop("disabled", false);
  2931. $("#undo").prop("disabled", true);
  2932. $("#swap").prop("disabled", true);
  2933. $("#peek").prop("disabled", true);
  2934. _setProgress(3);
  2935. break;
  2936. }
  2937. }
  2938. }
  2939.  
  2940. function resetUI() {
  2941. updateUI(SwapState.IDLE);
  2942. window.locked = false;
  2943. }
  2944.  
  2945. // overrides
  2946.  
  2947. function resetCanvas() {
  2948. graphics.clear();
  2949. graphics.beginFill(0xFFFFFF);
  2950. graphics.drawRect(0,0,800,600);
  2951. graphics.endFill();
  2952. graphics.lineStyle(3, 0x000000);
  2953. }
  2954.  
  2955. function sketch_setData(data) {
  2956. window.dat = `${data.trim()} `;
  2957.  
  2958. // using normal reset() would've left the wrong buttons enabled
  2959. // every time as if ink really was 0%.
  2960. resetCanvas();
  2961. resetUI();
  2962.  
  2963. const parts = data.split(" ");
  2964. for(var i = 0; i < parts.length; i++) {
  2965. let part = parts[i];
  2966. for(var j = 0; j < part.length; j += 4) {
  2967. var x = dec(part.substr(j, 2));
  2968. var y = dec(part.substr(j+2, 2));
  2969. if(j == 0) {
  2970. graphics.moveTo(x, y);
  2971. } else {
  2972. graphics.lineTo(x, y);
  2973. }
  2974. }
  2975. }
  2976. }
  2977.  
  2978. function noz_sketch_setData(arrdata) {
  2979. window.arrdat = arrdata = arrdata.filter((part) => part != "");
  2980.  
  2981. // using normal reset() would've left the wrong buttons enabled
  2982. // every time as if ink really was 0%.
  2983. resetCanvas();
  2984. resetUI();
  2985.  
  2986. for(var h = 0; h < arrdata.length; h++) {
  2987. const arrpart = arrdata[h];
  2988. const parts = arrpart.split(" ");
  2989. for(var i = 0; i < parts.length; i++) {
  2990. let part = parts[i];
  2991. for(var j = 0; j < part.length; j += 4) {
  2992. var x = dec(part.substr(j, 2));
  2993. var y = dec(part.substr(j+2, 2));
  2994. if(j == 0) {
  2995. graphics.moveTo(x, y);
  2996. } else {
  2997. graphics.lineTo(x, y);
  2998. }
  2999. }
  3000. }
  3001. }
  3002. }
  3003.  
  3004. function sketch_reset() {
  3005. window.dat = "";
  3006. window.lines = [];
  3007. window.autodrawpos = -1;
  3008. resetCanvas();
  3009. resetUI();
  3010. }
  3011.  
  3012. function noz_sketch_reset(manual) {
  3013. if(manual) {
  3014. window.backupdat = {
  3015. arrdat: window.arrdat,
  3016. screentoningPoints: window.screentoningPoints,
  3017. };
  3018. window.screentoningPoints = {};
  3019. saveIncomplete(false);
  3020. }
  3021.  
  3022. if(window.locked) {
  3023. window.backupdat = {};
  3024. }
  3025.  
  3026. window.dat = "";
  3027. window.arrdat = [];
  3028. window.autodrawpos = -1;
  3029. resetCanvas();
  3030. resetUI();
  3031. }
  3032.  
  3033. function swap() {
  3034. // lock the client *before* the swap request, gary
  3035. updateUI(SwapState.SWAPPING);
  3036. window.locked = true;
  3037.  
  3038. $.ajax({
  3039. url: `swap.php?db=${db || ""}&v=32`,
  3040. method: "POST",
  3041. data: window.dat,
  3042. error: function() {
  3043. alert("There was an error swapping.");
  3044. resetUI();
  3045. },
  3046. success: function(n) {
  3047. n = parseInt(n);
  3048. if(n < 0) {
  3049. alert(`On cooldown; please wait ${n} more seconds before swapping again.`);
  3050. resetUI();
  3051. return;
  3052. }
  3053. window.swapID = n;
  3054.  
  3055. updateUI(SwapState.PEEKING_FROM_SWAP);
  3056. attemptSwap();
  3057. },
  3058. });
  3059. }
  3060.  
  3061. function noz_swap() {
  3062. updateUI(SwapState.SWAPPING);
  3063. window.locked = true;
  3064.  
  3065. let dat = window.arrdat.join(" ") + " ";
  3066.  
  3067. $.ajax({
  3068. url: `https://garyc.me/sketch/swap.php?db=${db || ""}&v=32`,
  3069. method: "POST",
  3070. data: dat,
  3071. error: function() {
  3072. alert("There was an error swapping.");
  3073. resetUI();
  3074. },
  3075. success: function(n) {
  3076. n = parseInt(n);
  3077. if(n < 0) {
  3078. alert(`On cooldown; please wait ${n} more seconds before swapping again.`);
  3079. resetUI();
  3080. return;
  3081. }
  3082.  
  3083. window.swapID = n;
  3084. window.backupdat = {};
  3085. window.screentoningPoints = {};
  3086. saveIncomplete(false);
  3087. attemptSwap();
  3088. },
  3089. });
  3090. }
  3091.  
  3092. function attemptSwap() {
  3093. getStats();
  3094.  
  3095. $.ajax({
  3096. url: `https://garyc.me/sketch/get.php?id=${swapID}&db=${db || ""}`,
  3097. method: "GET",
  3098. error: function() {
  3099. setTimeout(attemptSwap, 2000);
  3100. },
  3101. success: function(result) {
  3102. if(result == "wait") {
  3103. updateUI(SwapState.WAITING_PEEK);
  3104. setTimeout(attemptSwap, 2000);
  3105. return;
  3106. }
  3107.  
  3108. switch(window.location.hostname) {
  3109. case "noz.rip":
  3110. drawData([result]);
  3111. break;
  3112. default:
  3113. drawData(result);
  3114. break;
  3115. }
  3116. getStats();
  3117. updateUI(SwapState.DONE_FROM_SWAP);
  3118. }
  3119. });
  3120. }
  3121.  
  3122. function getLatest() {
  3123. updateUI(SwapState.PEEKING);
  3124. window.locked = true;
  3125.  
  3126. $.ajax({
  3127. url: `https://garyc.me/sketch/get.php?db=${db || ""}`,
  3128. method: "GET",
  3129. error: function() {
  3130. alert("There was an error getting the latest sketch.");
  3131. resetUI();
  3132. },
  3133. success: function(result) {
  3134. switch(window.location.hostname) {
  3135. case "noz.rip":
  3136. drawData([result]);
  3137. break;
  3138. default:
  3139. drawData(result);
  3140. break;
  3141. }
  3142. getStats();
  3143. updateUI(SwapState.DONE);
  3144. }
  3145. });
  3146. }
  3147.  
  3148.  
  3149. if(window.location.pathname == "/sketch/") {
  3150. GM_addStyle(`
  3151. /* save button */
  3152. img[src="save.png"] {
  3153. /* shift 5px to the right.
  3154. i don't feel like making this button statically positioned
  3155. because there's whitespace text preceding it, and leaving or
  3156. relying on that might result in inconsistent positioning from,
  3157. say, font size changes...
  3158. doesn't seem easy to take out either in a userscript context,
  3159. unless i maybe go with regex, which i'm not insane enough to
  3160. tackle right now. */
  3161. left: 815px;
  3162. }
  3163.  
  3164. /* flash UI mimicking */
  3165. td input {
  3166. width: 100%;
  3167. height: 30px;
  3168. }
  3169. img[src="save.png"] {
  3170. opacity: .8;
  3171. }
  3172. img[src="save.png"]:hover {
  3173. opacity: 1;
  3174. }
  3175.  
  3176. /* progress bar */
  3177. td.swapContainer {
  3178. display: flex;
  3179. align-items: center;
  3180. }
  3181. td.swapContainer #swap {
  3182. flex: 2;
  3183. min-width: min-content;
  3184. }
  3185. td.swapContainer #progress {
  3186. flex: 3;
  3187. background-color: #f9f9f9;
  3188. border: 1px solid #767676;
  3189. border-radius: 4px;
  3190. height: 16px;
  3191. margin-top: 4px;
  3192. margin-left: 10px;
  3193. }
  3194. #progressBar {
  3195. height: 100%;
  3196. background-color: #a1ef55;
  3197. border-radius: 3px;
  3198. transition: width 0.15s ease;
  3199. }
  3200.  
  3201. /* personal tweaks */
  3202. td {
  3203. padding: 3px;
  3204. }
  3205. `);
  3206.  
  3207. // both noz.rip and garyc.me's JS happen at the document body,
  3208. // inject when that finishes loading
  3209.  
  3210. document.addEventListener("DOMContentLoaded", function() {
  3211. setInterval(window.getStats, 30000);
  3212.  
  3213. window.reset = sketch_reset;
  3214. window.setData = sketch_setData;
  3215. window.swap = swap;
  3216. window.attemptSwap = attemptSwap;
  3217. window.getLatest = getLatest;
  3218.  
  3219. // Fix ink limit from 50KiB to 64KiB, the largest amount of data that
  3220. // garyc.me can take in without truncating it.
  3221. window.limit = 64 * 1024;
  3222.  
  3223. // mark parent of swap button and add progress bar
  3224. // don't wanna use native <progress> due to lack of its styling
  3225. // support on firefox
  3226. const container = $("#swap").parent();
  3227. container.addClass("swapContainer");
  3228. container.append(`
  3229. <div id="progress"
  3230. role="progressbar"
  3231. aria-label="swap progress"
  3232. aria-valuenow="0"
  3233. aria-valuemin="0"
  3234. aria-valuemax="3">
  3235. <div id="progressBar" style="width: 0%"></div>
  3236. </div>
  3237. `);
  3238.  
  3239. $("img[src='save.png']").css({
  3240. left: "",
  3241. });
  3242.  
  3243. _updateSketchQuality(settings.sketchQuality);
  3244. });
  3245. }
  3246.  
  3247. if(window.location.pathname == "/sketch/" && window.location.hostname == "garyc.me") {
  3248. document.addEventListener("DOMContentLoaded", function() {
  3249. setInterval(window.update, 1000/30);
  3250. });
  3251. }
  3252.  
  3253. if(window.location.pathname == "/sketch/" && window.location.hostname == "noz.rip") {
  3254. document.addEventListener("DOMContentLoaded", function() {
  3255. // not sure how i'd monkeypatch update() here;
  3256. // it uses requestAnimationFrame instead of setInterval
  3257. setInterval(() => saveIncomplete(true), 10000);
  3258.  
  3259. window.reset = noz_sketch_reset;
  3260. window.setData = noz_sketch_setData;
  3261. window.swap = noz_swap;
  3262. });
  3263. }