lemmy-keyboard-navigation

10/07/2023

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

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