lemmy-keyboard-navigation

12/07/2023

当前为 2023-07-12 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name lemmy-keyboard-navigation
  3. // @namespace Violentmonkey Scripts
  4. // @match https://*/*
  5. // @grant none
  6. // @version 1.6
  7. // @author vmavromatis
  8. // @author howdy@thesimplecorner.org
  9. // @license GPL3
  10. // @description 12/07/2023
  11. // ==/UserScript==
  12.  
  13. //isLemmySite
  14. (function () {
  15. "use strict";
  16. if (!isLemmySite()) {
  17. return;
  18. }
  19. function isLemmySite() {
  20. return (
  21. document.querySelector('meta[name="Description"]').content === "Lemmy"
  22. );
  23. }
  24. // Set selected entry colors
  25. const backgroundColor = '#373737';
  26. const textColor = 'white';
  27.  
  28. // Set navigation keys with keycodes here: https://www.toptal.com/developers/keycode
  29. const nextKey = 'ArrowDown';
  30. const prevKey = 'ArrowUp';
  31. const expandKey = 'KeyX';
  32. const openCommentsKey = 'KeyC';
  33. const openLinkKey = 'Enter';
  34. const nextPageKey = 'ArrowRight';
  35. const prevPageKey = 'ArrowLeft';
  36. const upvoteKey = 'KeyA';
  37. const downvoteKey = 'KeyZ';
  38. const replyKey = 'KeyR';
  39.  
  40. // Stop arrows from moving the page
  41. window.addEventListener("keydown", function(e) {
  42. if(["ArrowUp","ArrowDown"].indexOf(e.code) > -1) {
  43. e.preventDefault();
  44. }
  45. }, false);
  46.  
  47. // Remove scroll animations
  48. document.documentElement.style = "scroll-behavior: auto";
  49.  
  50. // Set CSS for selected entry
  51. const css = [
  52. ".selected {",
  53. " background-color: " + backgroundColor + " !important;",
  54. " color: " + textColor + ";",
  55. "}"
  56. ].join("\n");
  57.  
  58. if (typeof GM_addStyle !== "undefined") {
  59. GM_addStyle(css);
  60. } else if (typeof PRO_addStyle !== "undefined") {
  61. PRO_addStyle(css);
  62. } else if (typeof addStyle !== "undefined") {
  63. addStyle(css);
  64. } else {
  65. let node = document.createElement("style");
  66. node.type = "text/css";
  67. node.appendChild(document.createTextNode(css));
  68. let heads = document.getElementsByTagName("head");
  69. if (heads.length > 0) {
  70. heads[0].appendChild(node);
  71. } else {
  72. // no head yet, stick it whereever
  73. document.documentElement.appendChild(node);
  74. }
  75. }
  76. const selectedClass = "selected";
  77.  
  78. let currentEntry;
  79. let commentBlock;
  80. let entries = [];
  81. let previousUrl = "";
  82. let expand = false;
  83.  
  84. const targetNode = document.documentElement;
  85. const config = { childList: true, subtree: true };
  86.  
  87. const observer = new MutationObserver(() => {
  88. entries = document.querySelectorAll(".post-listing, .comment-node");
  89.  
  90. if (entries.length > 0) {
  91. if (location.href !== previousUrl) {
  92. previousUrl = location.href;
  93. currentEntry = null;
  94. }
  95. init();
  96. }
  97. });
  98.  
  99. observer.observe(targetNode, config);
  100.  
  101. function init() {
  102. // If jumping to comments
  103. if (window.location.search.includes("scrollToComments=true") &&
  104. entries.length > 1 &&
  105. (!currentEntry || Array.from(entries).indexOf(currentEntry) < 0)
  106. ) {
  107. selectEntry(entries[1], true);
  108. }
  109. // If jumping to comment from anchor link
  110. else if (window.location.pathname.includes("/comment/") &&
  111. (!currentEntry || Array.from(entries).indexOf(currentEntry) < 0)
  112. ) {
  113. const commentId = window.location.pathname.replace("/comment/", "");
  114. const anchoredEntry = document.getElementById("comment-" + commentId);
  115.  
  116. if (anchoredEntry) {
  117. selectEntry(anchoredEntry, true);
  118. }
  119. }
  120. // If no entries yet selected, default to first
  121. else if (!currentEntry || Array.from(entries).indexOf(currentEntry) < 0) {
  122. selectEntry(entries[0]);
  123. }
  124.  
  125. Array.from(entries).forEach(entry => {
  126. entry.removeEventListener("click", clickEntry, true);
  127. entry.addEventListener('click', clickEntry, true);
  128. });
  129.  
  130. document.removeEventListener("keydown", handleKeyPress, true);
  131. document.addEventListener("keydown", handleKeyPress, true);
  132. }
  133.  
  134. function handleKeyPress(event) {
  135. if (["TEXTAREA", "INPUT"].indexOf(event.target.tagName) > -1) {
  136. return;
  137. }
  138.  
  139. switch (event.code) {
  140. case nextKey:
  141. case prevKey:
  142. let selectedEntry;
  143. // Next button
  144. if (event.code === nextKey) {
  145. selectedEntry = getNextEntry(currentEntry)
  146. }
  147. // Previous button
  148. if (event.code === prevKey) {
  149. selectedEntry = getPrevEntry(currentEntry)
  150. }
  151. if (selectedEntry) {
  152. if (expand) collapseEntry();
  153. selectEntry(selectedEntry, true);
  154. if (expand) expandEntry();
  155. }
  156. break;
  157. toggleExpand();
  158. expand = isExpanded() ? true : false;
  159. break;
  160. case upvoteKey:
  161. upVote();
  162. break;
  163. case downvoteKey:
  164. downVote();
  165. break;
  166. case replyKey:
  167. reply(event);
  168. break;
  169. case expandKey:
  170. toggleExpand();
  171. expand = isExpanded() ? true : false;
  172. break;
  173. case openCommentsKey:
  174. if (event.shiftKey) {
  175. window.open(
  176. currentEntry.querySelector("a.btn[title$='Comments']").href,
  177. );
  178. } else {
  179. currentEntry.querySelector("a.btn[title$='Comments']").click();
  180. }
  181. break;
  182. case openLinkKey:
  183. const linkElement = currentEntry.querySelector(".col.flex-grow-0.px-0>div>a")
  184. if (linkElement) {
  185. if (event.shiftKey) {
  186. window.open(linkElement.href);
  187. } else {
  188. linkElement.click();
  189. }
  190. }
  191. break;
  192. case nextPageKey:
  193. case prevPageKey:
  194. const pageButtons = Array.from(document.querySelectorAll(".paginator>button"));
  195.  
  196. if (pageButtons && (document.getElementsByClassName('paginator').length > 0)) {
  197. const buttonText = event.code === nextPageKey ? "Next" : "Prev";
  198. pageButtons.find(btn => btn.innerHTML === buttonText).click();
  199. }
  200. // Jump next block of comments
  201. if (event.code === nextPageKey) {
  202. commentBlock = getNextEntrySameLevel(currentEntry)
  203. }
  204. // Jump previous block of comments
  205. if (event.code === prevPageKey) {
  206. commentBlock = getPrevEntrySameLevel(currentEntry)
  207. }
  208.  
  209. if (commentBlock) {
  210. if (expand) collapseEntry();
  211. selectEntry(commentBlock, true);
  212. if (expand) expandEntry();
  213. }
  214. }
  215. }
  216.  
  217. function getNextEntry(e) {
  218. const currentEntryIndex = Array.from(entries).indexOf(e);
  219.  
  220. if (currentEntryIndex + 1 >= entries.length) {
  221. return e;
  222. }
  223.  
  224. return entries[currentEntryIndex + 1];
  225. }
  226.  
  227. function getPrevEntry(e) {
  228. const currentEntryIndex = Array.from(entries).indexOf(e);
  229.  
  230. if (currentEntryIndex - 1 < 0) {
  231. return e;
  232. }
  233.  
  234. return entries[currentEntryIndex - 1];
  235. }
  236.  
  237. function getNextEntrySameLevel(e) {
  238. const nextSibling = e.parentElement.nextElementSibling;
  239.  
  240. if (!nextSibling || nextSibling.getElementsByTagName("article").length < 1) {
  241. return getNextEntry(e);
  242. }
  243.  
  244. return nextSibling.getElementsByTagName("article")[0];
  245. }
  246.  
  247. function getPrevEntrySameLevel(e) {
  248. const prevSibling = e.parentElement.previousElementSibling;
  249.  
  250. if (!prevSibling || prevSibling.getElementsByTagName("article").length < 1) {
  251. return getPrevEntry(e);
  252. }
  253.  
  254. return prevSibling.getElementsByTagName("article")[0];
  255. }
  256.  
  257. function clickEntry(event) {
  258. const e = event.currentTarget;
  259. const target = event.target;
  260.  
  261. // Deselect if already selected, also ignore if clicking on any link/button
  262. if (e === currentEntry && e.classList.contains(selectedClass) &&
  263. !(
  264. target.tagName.toLowerCase() === "button" || target.tagName.toLowerCase() === "a" ||
  265. target.parentElement.tagName.toLowerCase() === "button" ||
  266. target.parentElement.tagName.toLowerCase() === "a" ||
  267. target.parentElement.parentElement.tagName.toLowerCase() === "button" ||
  268. target.parentElement.parentElement.tagName.toLowerCase() === "a"
  269. )
  270. ) {
  271. e.classList.remove(selectedClass);
  272. } else {
  273. selectEntry(e);
  274. }
  275. }
  276.  
  277. function selectEntry(e, scrollIntoView=false) {
  278. if (currentEntry) {
  279. currentEntry.classList.remove(selectedClass);
  280. }
  281. currentEntry = e;
  282. currentEntry.classList.add(selectedClass);
  283.  
  284. if (scrollIntoView) {
  285. scrollIntoViewWithOffset(e, 15)
  286. }
  287. }
  288.  
  289. function isExpanded() {
  290. if (
  291. currentEntry.querySelector("a.d-inline-block:not(.thumbnail)") ||
  292. currentEntry.querySelector("#postContent") ||
  293. currentEntry.querySelector(".card-body")
  294. ) {
  295. return true;
  296. }
  297.  
  298. return false;
  299. }
  300.  
  301. function upVote() {
  302. const upvoteButton = currentEntry.querySelector("button[aria-label='Upvote']");
  303.  
  304. if (upvoteButton) {
  305. upvoteButton.click();
  306. }
  307. }
  308.  
  309. function downVote() {
  310. const downvoteButton = currentEntry.querySelector("button[aria-label='Downvote']");
  311.  
  312. if (downvoteButton) {
  313. downvoteButton.click();
  314. }
  315. }
  316.  
  317. function reply(event) {
  318. const replyButton = currentEntry.querySelector("button[data-tippy-content='reply']");
  319.  
  320. if (replyButton) {
  321. event.preventDefault();
  322. replyButton.click();
  323. }
  324. }
  325.  
  326.  
  327. function toggleExpand() {
  328. const expandButton = currentEntry.querySelector("button[aria-label='Expand here']");
  329. const textExpandButton = currentEntry.querySelector(".post-title>button");
  330.  
  331. if (expandButton) {
  332. expandButton.click();
  333.  
  334. // Scroll into view if picture/text preview cut off
  335. const imgContainer = currentEntry.querySelector("a.d-inline-block");
  336.  
  337. if (imgContainer) {
  338. // Check container positions once image is loaded
  339. imgContainer.querySelector("img").addEventListener("load", function() {
  340. scrollIntoViewWithOffset(
  341. imgContainer,
  342. currentEntry.offsetHeight - imgContainer.offsetHeight + 10
  343. );
  344. }, true);
  345. }
  346. }
  347.  
  348. if (textExpandButton) {
  349. textExpandButton.click();
  350.  
  351. const textContainers = [currentEntry.querySelector("#postContent"), currentEntry.querySelector(".card-body")];
  352. textContainers.forEach(container => {
  353. if (container) {
  354. scrollIntoViewWithOffset(
  355. container,
  356. currentEntry.offsetHeight - container.offsetHeight + 10
  357. );
  358. }
  359. });
  360. }
  361. }
  362.  
  363. function expandEntry() {
  364. if (!isExpanded()) toggleExpand();
  365. }
  366.  
  367. function collapseEntry() {
  368. if (isExpanded()) toggleExpand();
  369. }
  370.  
  371. function scrollIntoViewWithOffset(e, offset) {
  372. if (e.getBoundingClientRect().top < 0 ||
  373. e.getBoundingClientRect().bottom > window.innerHeight
  374. ) {
  375. const y = e.getBoundingClientRect().top + window.pageYOffset - offset;
  376. window.scrollTo({
  377. top: y
  378. });
  379. }
  380.  
  381.  
  382. }
  383. })();
  384.