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 entries = [];
  80. let previousUrl = "";
  81. let expand = false;
  82.  
  83. const targetNode = document.documentElement;
  84. const config = { childList: true, subtree: true };
  85.  
  86. const observer = new MutationObserver(() => {
  87. entries = document.querySelectorAll(".post-listing, .comment-node");
  88.  
  89. if (entries.length > 0) {
  90. if (location.href !== previousUrl) {
  91. previousUrl = location.href;
  92. currentEntry = null;
  93. }
  94. init();
  95. }
  96. });
  97.  
  98. observer.observe(targetNode, config);
  99.  
  100. function init() {
  101. // If jumping to comments
  102. if (window.location.search.includes("scrollToComments=true") &&
  103. entries.length > 1 &&
  104. (!currentEntry || Array.from(entries).indexOf(currentEntry) < 0)
  105. ) {
  106. selectEntry(entries[1], true);
  107. }
  108. // If jumping to comment from anchor link
  109. else if (window.location.pathname.includes("/comment/") &&
  110. (!currentEntry || Array.from(entries).indexOf(currentEntry) < 0)
  111. ) {
  112. const commentId = window.location.pathname.replace("/comment/", "");
  113. const anchoredEntry = document.getElementById("comment-" + commentId);
  114.  
  115. if (anchoredEntry) {
  116. selectEntry(anchoredEntry, true);
  117. }
  118. }
  119. // If no entries yet selected, default to first
  120. else if (!currentEntry || Array.from(entries).indexOf(currentEntry) < 0) {
  121. selectEntry(entries[0]);
  122. }
  123.  
  124. Array.from(entries).forEach(entry => {
  125. entry.removeEventListener("click", clickEntry, true);
  126. entry.addEventListener('click', clickEntry, true);
  127. });
  128.  
  129. document.removeEventListener("keydown", handleKeyPress, true);
  130. document.addEventListener("keydown", handleKeyPress, true);
  131. }
  132.  
  133. function handleKeyPress(event) {
  134. if (["TEXTAREA", "INPUT"].indexOf(event.target.tagName) > -1) {
  135. return;
  136. }
  137.  
  138. switch (event.code) {
  139. case nextKey:
  140. case prevKey:
  141. let selectedEntry;
  142. // Next button
  143. if (event.code === nextKey) {
  144. selectedEntry = getNextEntry(currentEntry)
  145. }
  146. // Previous button
  147. if (event.code === prevKey) {
  148. selectedEntry = getPrevEntry(currentEntry)
  149. }
  150. if (selectedEntry) {
  151. if (expand) collapseEntry();
  152. selectEntry(selectedEntry, true);
  153. if (expand) expandEntry();
  154. }
  155. break;
  156. toggleExpand();
  157. expand = isExpanded() ? true : false;
  158. break;
  159. case upvoteKey:
  160. upVote();
  161. break;
  162. case downvoteKey:
  163. downVote();
  164. break;
  165. case replyKey:
  166. reply(event);
  167. break;
  168. case expandKey:
  169. toggleExpand();
  170. expand = isExpanded() ? true : false;
  171. break;
  172. case openCommentsKey:
  173. if (event.shiftKey) {
  174. window.open(
  175. currentEntry.querySelector("a.btn[title$='Comments']").href,
  176. );
  177. } else {
  178. currentEntry.querySelector("a.btn[title$='Comments']").click();
  179. }
  180. break;
  181. case openLinkKey:
  182. const linkElement = currentEntry.querySelector(".col.flex-grow-0.px-0>div>a")
  183. if (linkElement) {
  184. if (event.shiftKey) {
  185. window.open(linkElement.href);
  186. } else {
  187. linkElement.click();
  188. }
  189. }
  190. break;
  191. case nextPageKey:
  192. case prevPageKey:
  193. const pageButtons = Array.from(document.querySelectorAll(".paginator>button"));
  194.  
  195. if (pageButtons && (document.getElementsByClassName('paginator').length > 0)) {
  196. const buttonText = event.code === nextPageKey ? "Next" : "Prev";
  197. pageButtons.find(btn => btn.innerHTML === buttonText).click();
  198. }
  199. // Jump next block of comments
  200. if (event.code === nextPageKey) {
  201. commentBlock = getNextEntrySameLevel(currentEntry)
  202. }
  203. // Jump previous block of comments
  204. if (event.code === prevPageKey) {
  205. commentBlock = getPrevEntrySameLevel(currentEntry)
  206. }
  207.  
  208. if (commentBlock) {
  209. if (expand) collapseEntry();
  210. selectEntry(commentBlock, true);
  211. if (expand) expandEntry();
  212. }
  213. }
  214. }
  215.  
  216. function getNextEntry(e) {
  217. const currentEntryIndex = Array.from(entries).indexOf(e);
  218.  
  219. if (currentEntryIndex + 1 >= entries.length) {
  220. return e;
  221. }
  222.  
  223. return entries[currentEntryIndex + 1];
  224. }
  225.  
  226. function getPrevEntry(e) {
  227. const currentEntryIndex = Array.from(entries).indexOf(e);
  228.  
  229. if (currentEntryIndex - 1 < 0) {
  230. return e;
  231. }
  232.  
  233. return entries[currentEntryIndex - 1];
  234. }
  235.  
  236. function getNextEntrySameLevel(e) {
  237. const nextSibling = e.parentElement.nextElementSibling;
  238.  
  239. if (!nextSibling || nextSibling.getElementsByTagName("article").length < 1) {
  240. return getNextEntry(e);
  241. }
  242.  
  243. return nextSibling.getElementsByTagName("article")[0];
  244. }
  245.  
  246. function getPrevEntrySameLevel(e) {
  247. const prevSibling = e.parentElement.previousElementSibling;
  248.  
  249. if (!prevSibling || prevSibling.getElementsByTagName("article").length < 1) {
  250. return getPrevEntry(e);
  251. }
  252.  
  253. return prevSibling.getElementsByTagName("article")[0];
  254. }
  255.  
  256. function clickEntry(event) {
  257. const e = event.currentTarget;
  258. const target = event.target;
  259.  
  260. // Deselect if already selected, also ignore if clicking on any link/button
  261. if (e === currentEntry && e.classList.contains(selectedClass) &&
  262. !(
  263. target.tagName.toLowerCase() === "button" || target.tagName.toLowerCase() === "a" ||
  264. target.parentElement.tagName.toLowerCase() === "button" ||
  265. target.parentElement.tagName.toLowerCase() === "a" ||
  266. target.parentElement.parentElement.tagName.toLowerCase() === "button" ||
  267. target.parentElement.parentElement.tagName.toLowerCase() === "a"
  268. )
  269. ) {
  270. e.classList.remove(selectedClass);
  271. } else {
  272. selectEntry(e);
  273. }
  274. }
  275.  
  276. function selectEntry(e, scrollIntoView=false) {
  277. if (currentEntry) {
  278. currentEntry.classList.remove(selectedClass);
  279. }
  280. currentEntry = e;
  281. currentEntry.classList.add(selectedClass);
  282.  
  283. if (scrollIntoView) {
  284. scrollIntoViewWithOffset(e, 15)
  285. }
  286. }
  287.  
  288. function isExpanded() {
  289. if (
  290. currentEntry.querySelector("a.d-inline-block:not(.thumbnail)") ||
  291. currentEntry.querySelector("#postContent") ||
  292. currentEntry.querySelector(".card-body")
  293. ) {
  294. return true;
  295. }
  296.  
  297. return false;
  298. }
  299.  
  300. function upVote() {
  301. const upvoteButton = currentEntry.querySelector("button[aria-label='Upvote']");
  302.  
  303. if (upvoteButton) {
  304. upvoteButton.click();
  305. }
  306. }
  307.  
  308. function downVote() {
  309. const downvoteButton = currentEntry.querySelector("button[aria-label='Downvote']");
  310.  
  311. if (downvoteButton) {
  312. downvoteButton.click();
  313. }
  314. }
  315.  
  316. function reply(event) {
  317. const replyButton = currentEntry.querySelector("button[data-tippy-content='reply']");
  318.  
  319. if (replyButton) {
  320. event.preventDefault();
  321. replyButton.click();
  322. }
  323. }
  324.  
  325.  
  326. function toggleExpand() {
  327. const expandButton = currentEntry.querySelector("button[aria-label='Expand here']");
  328. const textExpandButton = currentEntry.querySelector(".post-title>button");
  329.  
  330. if (expandButton) {
  331. expandButton.click();
  332.  
  333. // Scroll into view if picture/text preview cut off
  334. const imgContainer = currentEntry.querySelector("a.d-inline-block");
  335.  
  336. if (imgContainer) {
  337. // Check container positions once image is loaded
  338. imgContainer.querySelector("img").addEventListener("load", function() {
  339. scrollIntoViewWithOffset(
  340. imgContainer,
  341. currentEntry.offsetHeight - imgContainer.offsetHeight + 10
  342. );
  343. }, true);
  344. }
  345. }
  346.  
  347. if (textExpandButton) {
  348. textExpandButton.click();
  349.  
  350. const textContainers = [currentEntry.querySelector("#postContent"), currentEntry.querySelector(".card-body")];
  351. textContainers.forEach(container => {
  352. if (container) {
  353. scrollIntoViewWithOffset(
  354. container,
  355. currentEntry.offsetHeight - container.offsetHeight + 10
  356. );
  357. }
  358. });
  359. }
  360. }
  361.  
  362. function expandEntry() {
  363. if (!isExpanded()) toggleExpand();
  364. }
  365.  
  366. function collapseEntry() {
  367. if (isExpanded()) toggleExpand();
  368. }
  369.  
  370. function scrollIntoViewWithOffset(e, offset) {
  371. if (e.getBoundingClientRect().top < 0 ||
  372. e.getBoundingClientRect().bottom > window.innerHeight
  373. ) {
  374. const y = e.getBoundingClientRect().top + window.pageYOffset - offset;
  375. window.scrollTo({
  376. top: y
  377. });
  378. }
  379.  
  380.  
  381. }
  382. })();
  383.