lemmy-keyboard-navigation

Easily navigate Lemmy with keyboard arrows

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

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