Matrix Element Media Navigation

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

目前為 2025-03-02 提交的版本,檢視 最新版本

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