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