lemmy-keyboard-navigation

Easily navigate Lemmy with keyboard arrows

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

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