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 helping a bit against the jumps on timeline pagination/scrolling issue #8565

目前为 2025-01-14 提交的版本,查看 最新版本

  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 helping a bit against the jumps on timeline pagination/scrolling issue #8565
  4. // @version 20250114
  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. const activeElementClass = "active-element";
  24.  
  25. // =======================================================================================
  26. // Layout
  27. // =======================================================================================
  28.  
  29. GM_addStyle(`
  30. /* Lightbox */
  31. img.mx_ImageView_image.mx_ImageView_image_animating,
  32. img.mx_ImageView_image.mx_ImageView_image_animatingLoading {
  33. transition: transform 0.01s ease;
  34. transition: none !important;
  35. transform: unset !important;
  36. width: 100% !important;
  37. height: 100% !important;
  38. object-fit: contain;
  39. }
  40. .mx_ImageView > .mx_ImageView_image_wrapper > img {
  41. cursor: default !important;
  42. }
  43. /* unused lightbox header */
  44. .mx_ImageView_panel {
  45. display: none;
  46. }
  47. .active-element > div.mx_EventTile_line.mx_EventTile_mediaLine.mx_EventTile_image {
  48. box-shadow: 0 0 2px 2px #007a62;
  49. background-color: var(--cpd-color-bg-subtle-secondary);
  50. }
  51.  
  52. `);
  53.  
  54. // =======================================================================================
  55. // General Functions
  56. // =======================================================================================
  57.  
  58. /**
  59. * Waits for an element to exist in the DOM with an optional timeout.
  60. * @param {string} selector - CSS selector.
  61. * @param {number} index - Index in NodeList/HTMLCollection.
  62. * @param {number} timeout - Maximum wait time in milliseconds.
  63. * @returns {Promise<Element|null>} - Resolves with the element or null if timeout.
  64. */
  65. function waitForElement(selector, index = 0, timeout = 5000) {
  66. return new Promise((resolve) => {
  67. const checkElement = () => document.querySelectorAll(selector)[index];
  68. if (checkElement()) {
  69. return resolve(checkElement());
  70. }
  71.  
  72. const observer = new MutationObserver(() => {
  73. if (checkElement()) {
  74. observer.disconnect();
  75. resolve(checkElement());
  76. }
  77. });
  78.  
  79. observer.observe(document.body, { childList: true, subtree: true });
  80.  
  81. if (timeout) {
  82. setTimeout(() => {
  83. observer.disconnect();
  84. resolve(null);
  85. }, timeout);
  86. }
  87. });
  88. }
  89.  
  90. /**
  91. * Determines the wheel direction and triggers the lightbox replacement.
  92. * @param {WheelEvent} event - Wheel event.
  93. */
  94. function getWheelDirection(event) {
  95. event.stopPropagation();
  96.  
  97. const direction = event.deltaY < 0 ? "up" : "down";
  98. navigateTo(direction);
  99. }
  100.  
  101. /**
  102. * Checks if the element is the last in a NodeList.
  103. * @param {Element} element - DOM element to check.
  104. * @returns {boolean} - True if last element, false otherwise.
  105. */
  106. function isLastElement(element) {
  107. const allElements = document.querySelectorAll(messageContainerSelector);
  108. return element === allElements[allElements.length - 1];
  109. }
  110.  
  111. /**
  112. * Finds the closest element to the vertical center of the viewport.
  113. * @returns {Element|null} - Closest element or null.
  114. */
  115. function getCurrentElement() {
  116. const elements = document.querySelectorAll(messageContainerSelector);
  117. let closestElement = null;
  118. let closestDistance = Infinity;
  119.  
  120. elements.forEach((element) => {
  121. const rect = element.getBoundingClientRect();
  122. const distance = Math.abs(rect.top + rect.height / 2 - window.innerHeight / 2);
  123. if (distance < closestDistance) {
  124. closestDistance = distance;
  125. closestElement = element;
  126. }
  127. });
  128.  
  129. return closestElement;
  130. }
  131.  
  132. /**
  133. * Navigates to the next or previous element and sets it as active.
  134. * @param {string} direction - "up" or "down".
  135. */
  136. function navigateTo(direction) {
  137. // const currentElement = document.querySelector(`.${activeClass}`) || getCurrentElement();
  138. let currentElement;
  139. if (document.querySelector(`.${activeElementClass}`)) {
  140. currentElement = document.querySelector(`.${activeElementClass}`);
  141. } else {
  142. console.error("activeElement not found");
  143. currentElement = getCurrentElement();
  144. }
  145. const siblingType = direction === "down" ? "nextElementSibling" : "previousElementSibling";
  146. const nextActiveElement = findSibling(currentElement, siblingType);
  147.  
  148. if (nextActiveElement) {
  149. console.log("nextActiveElement: ", nextActiveElement);
  150. setActiveElement(nextActiveElement);
  151. }
  152.  
  153. if (document.querySelector(".mx_Dialog_lightbox")) {
  154. replaceContentInLightbox();
  155. }
  156. }
  157.  
  158. /**
  159. * Sets an element as the active one and scrolls it into view.
  160. * @param {Element} nextActiveElement - DOM element to set active.
  161. */
  162. function setActiveElement(nextActiveElement) {
  163. if (nextActiveElement) {
  164. removeActiveElement();
  165.  
  166. nextActiveElement.classList.add(activeElementClass);
  167. nextActiveElement.scrollIntoView({
  168. block: isLastElement(nextActiveElement) ? "end" : "center",
  169. behavior: "auto",
  170. });
  171. } else {
  172. console.error("setActiveElement: nextActiveElement not found");
  173. }
  174. }
  175.  
  176. /**
  177. * Removes the "active-element" class from the currently active element.
  178. *
  179. * This function searches for an element with the class name stored in the
  180. * variable `activeElementClass` and removes the "active-element" class from it.
  181. * If no such element is found, the function does nothing.
  182. */
  183. function removeActiveElement() {
  184. const activeElement = document.querySelector(`.${activeElementClass}`); // Find the currently active element
  185. if (activeElement) {
  186. console.error("removeActiveElement");
  187. activeElement.classList.remove("active-element"); // Remove the active class
  188. }
  189. }
  190.  
  191. /**
  192. * Finds a sibling element matching the media item criteria.
  193. * @param {Element} startElement - Starting element.
  194. * @param {string} siblingType - "nextElementSibling" or "previousElementSibling".
  195. * @returns {Element|null} - Matching sibling or null.
  196. */
  197. function findSibling(startElement, siblingType) {
  198. let sibling = startElement?.[siblingType];
  199.  
  200. while (sibling) {
  201. // there must be a picture or video in the post
  202. if (
  203. sibling.matches(messageContainerSelector) &&
  204. sibling.querySelector("div.mx_EventTile_line.mx_EventTile_mediaLine.mx_EventTile_image, video.mx_MVideoBody")
  205. ) {
  206. return sibling;
  207. }
  208. sibling = sibling[siblingType];
  209. }
  210.  
  211. return null;
  212. }
  213.  
  214. // =======================================================================================
  215. // Specific Functions
  216. // =======================================================================================
  217.  
  218. /**
  219. * Closes the image lightbox and scrolls the active element into view.
  220. */
  221. function closeImageBox() {
  222. const currentElement = getCurrentElement();
  223. if (currentElement) {
  224. setActiveElement(currentElement);
  225. }
  226.  
  227. const closeButton = document.querySelector(".mx_AccessibleButton.mx_ImageView_button.mx_ImageView_button_close");
  228. if (closeButton) closeButton.click();
  229.  
  230. let attempts = 0;
  231. const maxAttempts = 10;
  232.  
  233. function checkScroll() {
  234. const rect = currentElement.getBoundingClientRect();
  235. const isInView = rect.top >= 0 && rect.bottom <= window.innerHeight;
  236.  
  237. if (!isInView && attempts < maxAttempts) {
  238. currentElement.scrollIntoView({
  239. block: isLastElement(currentElement) ? "end" : "center",
  240. behavior: "auto",
  241. });
  242. attempts++;
  243. } else {
  244. clearInterval(scrollCheckInterval);
  245. }
  246. }
  247.  
  248. const scrollCheckInterval = setInterval(checkScroll, 200);
  249. }
  250.  
  251. /**
  252. * Replaces the content of the lightbox with the next or previous picture depending on Mouse Wheel or cursor direction
  253. *
  254. * @param {string} direction u=Up or d=Down
  255. */
  256. function replaceContentInLightbox() {
  257. let imageLightboxSelector = document.querySelector(
  258. ".mx_Dialog_lightbox .mx_ImageView_image_wrapper > img, .mx_Dialog_lightbox .mx_ImageView_image_wrapper > video"
  259. );
  260. if (!imageLightboxSelector) return;
  261.  
  262. imageLightboxSelector.setAttribute("controls", "");
  263.  
  264. let currentElement = document.querySelector(`.${activeElementClass}`);
  265. if (!currentElement) {
  266. currentElement = getCurrentElement();
  267. }
  268.  
  269. // Update the lightbox content with the new media source
  270. if (currentElement) {
  271. let imageSource;
  272. // with HQ images the switch to the next image is slower
  273. const getHqImages = false;
  274. if (getHqImages) {
  275. imageSource = currentElement
  276. .querySelector(
  277. "div.mx_EventTile_line.mx_EventTile_mediaLine.mx_EventTile_image img.mx_MImageBody_thumbnail, video.mx_MVideoBody"
  278. )
  279. ?.src.replace(/thumbnail/, "download");
  280. } else {
  281. imageSource = currentElement.querySelector(
  282. "div.mx_EventTile_line.mx_EventTile_mediaLine.mx_EventTile_image img.mx_MImageBody_thumbnail, video.mx_MVideoBody"
  283. )?.src;
  284. }
  285.  
  286. imageLightboxSelector.src = imageSource;
  287.  
  288. // Switch between <img> and <video> tags based on the new media element
  289. if (currentElement.querySelector("video") && imageLightboxSelector?.tagName === "IMG") {
  290. imageLightboxSelector.parentElement.innerHTML = imageLightboxSelector.parentElement.innerHTML.replace(/^<img/, "<video");
  291.  
  292. setTimeout(() => {
  293. imageLightboxSelector.setAttribute("controls", "");
  294. }, 300);
  295. }
  296. if (currentElement.querySelector("img") && imageLightboxSelector?.tagName === "VIDEO") {
  297. imageLightboxSelector.parentElement.innerHTML = imageLightboxSelector.parentElement.innerHTML.replace(/^<video/, "<img");
  298. }
  299. }
  300. }
  301.  
  302. // =======================================================================================
  303. // Main
  304. // =======================================================================================
  305.  
  306. function main() {
  307. document.addEventListener(
  308. "keydown",
  309. function (event) {
  310. // if in lightbox
  311. if (document.querySelector(".mx_Dialog_lightbox")) {
  312. if (event.key === "Escape") {
  313. event.stopPropagation();
  314. closeImageBox();
  315. }
  316. }
  317. // Navigation
  318. if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
  319. event.preventDefault();
  320. navigateTo("up");
  321. } else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
  322. event.preventDefault();
  323. navigateTo("down");
  324. }
  325. },
  326. true
  327. );
  328.  
  329. // add listeners only once
  330. let lightboxListenersAdded = false;
  331. let timelineListenerAdded = false;
  332.  
  333. const observer = new MutationObserver(() => {
  334. const lightbox = document.querySelector(".mx_Dialog_lightbox");
  335.  
  336. if (lightbox && !lightboxListenersAdded) {
  337. // Remove timeline wheel listener when lightbox opens
  338. if (timelineListenerAdded) {
  339. document.removeEventListener("wheel", removeActiveElement, { passive: false });
  340. timelineListenerAdded = false;
  341. }
  342.  
  343. waitForElement(".mx_ImageView").then((element) => {
  344. // Check if the event listeners are already added
  345. if (!element._listenersAdded) {
  346. element.addEventListener("click", (event) => {
  347. const target = event.target;
  348. // Close lightbox if clicking the background
  349. if (target.matches(".mx_ImageView > .mx_ImageView_image_wrapper > img")) {
  350. closeImageBox();
  351. }
  352. });
  353.  
  354. element.addEventListener("wheel", getWheelDirection, { passive: false });
  355.  
  356. // Mark the listener as added
  357. element._listenersAdded = true;
  358. }
  359.  
  360. lightboxListenersAdded = true;
  361. }, true);
  362. // Timeline view mode
  363. } else if (!lightbox && !timelineListenerAdded) {
  364. // remove ActiveElement in timeline view to allow scrolling
  365. document.addEventListener("wheel", removeActiveElement, { passive: false });
  366.  
  367. timelineListenerAdded = true;
  368. lightboxListenersAdded = false; // Reset the lightbox listener flag when lightbox is closed
  369. }
  370. });
  371.  
  372. // to detect when the light box is switched on or off
  373. observer.observe(document.body, { childList: true, subtree: true });
  374. }
  375.  
  376. if (/^element\.[^.]+\.[^.]+$/.test(document.location.host) || /^matrixclient\.[^.]+\.[^.]+$/.test(document.location.host)) {
  377. main();
  378. }