Matrix Element Media Navigation

Enables navigation through images and videos in timeline (up/down & left/right keys) and lightbox (same keys + mousewheel) view. Its also a workaround against the jumps on timeline pagination/scrolling issue #8565

目前為 2024-12-01 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Matrix Element Media Navigation
  3. // @description Enables navigation through images and videos in timeline (up/down & left/right keys) and lightbox (same keys + mousewheel) view. Its also a workaround against the jumps on timeline pagination/scrolling issue #8565
  4. // @version 20241201a
  5. // @author resykano
  6. // @icon https://icons.duckduckgo.com/ip2/element.io.ico
  7. // @match *://*/*
  8. // @grant GM_xmlhttpRequest
  9. // @grant GM_addStyle
  10. // @compatible chrome
  11. // @license GPL3
  12. // @noframes
  13. // @namespace https://greasyfork.org/users/1342111
  14. // ==/UserScript==
  15.  
  16. "use strict";
  17.  
  18. // =======================================================================================
  19. // Config/Requirements
  20. // =======================================================================================
  21.  
  22. let messageContainerSelector = "ol.mx_RoomView_MessageList li.mx_EventTile";
  23.  
  24. // =======================================================================================
  25. // Layout
  26. // =======================================================================================
  27.  
  28. GM_addStyle(`
  29. .mx_ImageView_image.mx_ImageView_image_animatingLoading {
  30. transition: transform 0.01s ease;
  31. }
  32. .active-element > div.mx_EventTile_line.mx_EventTile_mediaLine.mx_EventTile_image {
  33. box-shadow: 0 0 2px 2px #007a62;
  34. background-color: var(--cpd-color-bg-subtle-secondary);
  35. }
  36. `);
  37.  
  38. // =======================================================================================
  39. // General Functions
  40. // =======================================================================================
  41.  
  42. /**
  43. * Waits for an element to exist in the DOM with an optional timeout.
  44. * @param {string} selector - CSS selector.
  45. * @param {number} index - Index in NodeList/HTMLCollection.
  46. * @param {number} timeout - Maximum wait time in milliseconds.
  47. * @returns {Promise<Element|null>} - Resolves with the element or null if timeout.
  48. */
  49. function waitForElement(selector, index = 0, timeout = 5000) {
  50. return new Promise((resolve) => {
  51. const checkElement = () => document.querySelectorAll(selector)[index];
  52. if (checkElement()) {
  53. return resolve(checkElement());
  54. }
  55.  
  56. const observer = new MutationObserver(() => {
  57. if (checkElement()) {
  58. observer.disconnect();
  59. resolve(checkElement());
  60. }
  61. });
  62.  
  63. observer.observe(document.body, { childList: true, subtree: true });
  64.  
  65. if (timeout) {
  66. setTimeout(() => {
  67. observer.disconnect();
  68. resolve(null);
  69. }, timeout);
  70. }
  71. });
  72. }
  73.  
  74. /**
  75. * Determines the wheel direction and triggers the lightbox replacement.
  76. * @param {WheelEvent} event - Wheel event.
  77. */
  78. function getWheelDirection(event) {
  79. const direction = event.deltaY < 0 ? "up" : "down";
  80. navigateTo(direction);
  81. replaceContentInLightbox();
  82. }
  83.  
  84. /**
  85. * Checks if the element is the last in a NodeList.
  86. * @param {Element} element - DOM element to check.
  87. * @returns {boolean} - True if last element, false otherwise.
  88. */
  89. function isLastElement(element) {
  90. const allElements = document.querySelectorAll(messageContainerSelector);
  91. return element === allElements[allElements.length - 1];
  92. }
  93.  
  94. /**
  95. * Finds the closest element to the vertical center of the viewport.
  96. * @returns {Element|null} - Closest element or null.
  97. */
  98. function getCurrentElement() {
  99. const elements = document.querySelectorAll(messageContainerSelector);
  100. let closestElement = null;
  101. let closestDistance = Infinity;
  102.  
  103. elements.forEach((element) => {
  104. const rect = element.getBoundingClientRect();
  105. const distance = Math.abs(rect.top + rect.height / 2 - window.innerHeight / 2);
  106. if (distance < closestDistance) {
  107. closestDistance = distance;
  108. closestElement = element;
  109. }
  110. });
  111.  
  112. return closestElement;
  113. }
  114.  
  115. /**
  116. * Navigates to the next or previous element and sets it as active.
  117. * @param {string} direction - "up" or "down".
  118. */
  119. function navigateTo(direction) {
  120. const currentElement = document.querySelector("[data-active]") || getCurrentElement();
  121. const siblingType = direction === "down" ? "nextElementSibling" : "previousElementSibling";
  122. const nextElement = findSibling(currentElement, siblingType);
  123.  
  124. if (nextElement) {
  125. setActiveElement(nextElement);
  126. }
  127. }
  128.  
  129. /**
  130. * Sets an element as the active one and scrolls it into view.
  131. * @param {Element} element - DOM element to set active.
  132. */
  133. function setActiveElement(element) {
  134. const activeClass = "active-element";
  135. const currentActive = document.querySelector(`.${activeClass}`);
  136. if (currentActive) {
  137. currentActive.classList.remove(activeClass);
  138. currentActive.removeAttribute("data-active");
  139. }
  140.  
  141. if (element) {
  142. element.classList.add(activeClass);
  143. element.setAttribute("data-active", "true");
  144. element.scrollIntoView({
  145. block: isLastElement(element) ? "end" : "center",
  146. behavior: "auto",
  147. });
  148. }
  149. }
  150.  
  151. function removeActiveElement() {
  152. const activeElement = document.querySelector("[data-active]"); // Find the currently active element
  153. if (activeElement) {
  154. activeElement.classList.remove("active-element"); // Remove the active class
  155. activeElement.removeAttribute("data-active"); // Remove the data-active attribute
  156. }
  157. }
  158.  
  159. /**
  160. * Finds a sibling element matching the media item criteria.
  161. * @param {Element} startElement - Starting element.
  162. * @param {string} siblingType - "nextElementSibling" or "previousElementSibling".
  163. * @returns {Element|null} - Matching sibling or null.
  164. */
  165. function findSibling(startElement, siblingType) {
  166. let sibling = startElement?.[siblingType];
  167.  
  168. while (sibling) {
  169. // there must be a picture or video in the post
  170. if (
  171. sibling.matches(messageContainerSelector) &&
  172. sibling.querySelector("div.mx_EventTile_line.mx_EventTile_mediaLine.mx_EventTile_image, video.mx_MVideoBody")
  173. ) {
  174. return sibling;
  175. }
  176. sibling = sibling[siblingType];
  177. }
  178.  
  179. return null;
  180. }
  181.  
  182. // =======================================================================================
  183. // Specific Functions
  184. // =======================================================================================
  185.  
  186. /**
  187. * Closes the image lightbox and scrolls the active element into view.
  188. */
  189. function closeImageBox() {
  190. const currentElement = getCurrentElement();
  191. if (currentElement) {
  192. setActiveElement(currentElement);
  193. }
  194.  
  195. const closeButton = document.querySelector(".mx_AccessibleButton.mx_ImageView_button.mx_ImageView_button_close");
  196. if (closeButton) closeButton.click();
  197.  
  198. let attempts = 0;
  199. const maxAttempts = 10;
  200.  
  201. function checkScroll() {
  202. const rect = currentElement.getBoundingClientRect();
  203. const isInView = rect.top >= 0 && rect.bottom <= window.innerHeight;
  204.  
  205. if (!isInView && attempts < maxAttempts) {
  206. currentElement.scrollIntoView({
  207. block: isLastElement(currentElement) ? "end" : "center",
  208. behavior: "auto",
  209. });
  210. attempts++;
  211. } else {
  212. clearInterval(scrollCheckInterval);
  213. }
  214. }
  215.  
  216. const scrollCheckInterval = setInterval(checkScroll, 200);
  217. }
  218.  
  219. /**
  220. * Replaces the content of the lightbox with the next or previous picture depending on Mouse Wheel or cursor direction
  221. *
  222. * @param {string} direction u=Up or d=Down
  223. */
  224. function replaceContentInLightbox() {
  225. let imageLightboxSelector = document.querySelector(
  226. ".mx_Dialog_lightbox .mx_ImageView_image_wrapper > img, .mx_Dialog_lightbox .mx_ImageView_image_wrapper > video"
  227. );
  228. if (!imageLightboxSelector) return;
  229.  
  230. imageLightboxSelector.setAttribute("controls", "");
  231.  
  232. let currentElement = document.querySelector("[data-active]");
  233. if (!currentElement) {
  234. currentElement = getCurrentElement();
  235. }
  236.  
  237. // Update the lightbox content with the new media source
  238. if (currentElement) {
  239. let imageSource;
  240. // with HQ images the switch to the next image is slower
  241. const getHqImages = false;
  242. if (getHqImages) {
  243. imageSource = currentElement
  244. .querySelector(
  245. "div.mx_EventTile_line.mx_EventTile_mediaLine.mx_EventTile_image img.mx_MImageBody_thumbnail, video.mx_MVideoBody"
  246. )
  247. ?.src.replace(/thumbnail/, "download");
  248. } else {
  249. imageSource = currentElement.querySelector(
  250. "div.mx_EventTile_line.mx_EventTile_mediaLine.mx_EventTile_image img.mx_MImageBody_thumbnail, video.mx_MVideoBody"
  251. )?.src;
  252. }
  253.  
  254. imageLightboxSelector.src = imageSource;
  255.  
  256. // Switch between <img> and <video> tags based on the new media element
  257. if (currentElement.querySelector("video") && imageLightboxSelector?.tagName === "IMG") {
  258. imageLightboxSelector.parentElement.innerHTML = imageLightboxSelector.parentElement.innerHTML.replace(/^<img/, "<video");
  259.  
  260. setTimeout(() => {
  261. imageLightboxSelector.setAttribute("controls", "");
  262. }, 300);
  263. }
  264. if (currentElement.querySelector("img") && imageLightboxSelector?.tagName === "VIDEO") {
  265. imageLightboxSelector.parentElement.innerHTML = imageLightboxSelector.parentElement.innerHTML.replace(/^<video/, "<img");
  266. }
  267. }
  268. }
  269.  
  270. // =======================================================================================
  271. // Main
  272. // =======================================================================================
  273.  
  274. function main() {
  275. document.addEventListener(
  276. "keydown",
  277. function (event) {
  278. if (document.querySelector(".mx_Dialog_lightbox")) {
  279. // Navigation in lightbox
  280. if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
  281. event.preventDefault();
  282. navigateTo("up");
  283. replaceContentInLightbox();
  284. } else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
  285. event.preventDefault();
  286. navigateTo("down");
  287. replaceContentInLightbox();
  288. } else if (event.key === "Escape") {
  289. event.stopPropagation();
  290. closeImageBox();
  291. }
  292. } else {
  293. // Navigation in timeline
  294. if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
  295. event.preventDefault();
  296. navigateTo("up");
  297. } else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
  298. event.preventDefault();
  299. navigateTo("down");
  300. }
  301. }
  302. },
  303. true
  304. );
  305.  
  306. const observer = new MutationObserver(() => {
  307. const lightbox = document.querySelector(".mx_Dialog_lightbox");
  308. if (lightbox) {
  309. waitForElement(".mx_ImageView").then((element) => {
  310. element.addEventListener("mousedown", closeImageBox);
  311. element.addEventListener("wheel", getWheelDirection, { passive: false });
  312. }, true);
  313. } else {
  314. document.addEventListener("wheel", removeActiveElement, { passive: false });
  315. }
  316. });
  317.  
  318. observer.observe(document.body, { childList: true, subtree: true });
  319. }
  320.  
  321. if (/^element\.[^.]+\.[^.]+$/.test(document.location.host)) {
  322. main();
  323. }