lemmy-keyboard-navigation

Easily navigate Lemmy with keyboard arrows

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

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