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-04-21 提交的版本。查看 最新版本

  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 20250421
  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. // console.log("checkScroll: ", attempts);
  237. const rect = currentElement.getBoundingClientRect();
  238. const isInView = rect.top >= 0 && rect.bottom <= window.innerHeight;
  239.  
  240. if (!isInView && attempts < maxAttempts) {
  241. currentElement.scrollIntoView({
  242. block: isLastElement(currentElement) ? "end" : "center",
  243. behavior: "auto",
  244. });
  245. attempts++;
  246. } else if (isInView) {
  247. // Check a second time after a short delay
  248. setTimeout(() => {
  249. const rectAfterScroll = currentElement.getBoundingClientRect();
  250. const isStillInView = rectAfterScroll.top >= 0 && rectAfterScroll.bottom <= window.innerHeight;
  251. if (!isStillInView && attempts < maxAttempts) {
  252. currentElement.scrollIntoView({
  253. block: isLastElement(currentElement) ? "end" : "center",
  254. behavior: "auto",
  255. });
  256. attempts++;
  257. } else {
  258. clearInterval(scrollCheckInterval);
  259. }
  260. }, 200); // Adjust the delay as needed
  261. } else {
  262. clearInterval(scrollCheckInterval);
  263. }
  264. }
  265.  
  266. const scrollCheckInterval = setInterval(checkScroll, 200);
  267. }
  268.  
  269. /**
  270. * Replaces the content of the lightbox with the next or previous picture depending on Mouse Wheel or cursor direction
  271. *
  272. * @param {string} direction u=Up or d=Down
  273. */
  274. function replaceContentInLightbox() {
  275. let imageLightboxSelector = document.querySelector(
  276. ".mx_Dialog_lightbox .mx_ImageView_image_wrapper > img, .mx_Dialog_lightbox .mx_ImageView_image_wrapper > video"
  277. );
  278. if (!imageLightboxSelector) return;
  279.  
  280. imageLightboxSelector.setAttribute("controls", "");
  281.  
  282. let currentElement = getActiveMedia();
  283. if (!currentElement) {
  284. currentElement = getCurrentElement();
  285. }
  286.  
  287. // Update the lightbox content with the new media source
  288. if (currentElement) {
  289. let imageSource;
  290. // with HQ images the switch to the next image is slower
  291. const getHqImages = false;
  292. if (getHqImages) {
  293. imageSource = currentElement
  294. .querySelector(
  295. "div.mx_EventTile_line.mx_EventTile_mediaLine.mx_EventTile_image img.mx_MImageBody_thumbnail, video.mx_MVideoBody"
  296. )
  297. ?.src.replace(/thumbnail/, "download");
  298. } else {
  299. imageSource = currentElement.querySelector(
  300. "div.mx_EventTile_line.mx_EventTile_mediaLine.mx_EventTile_image img.mx_MImageBody_thumbnail, video.mx_MVideoBody"
  301. )?.src;
  302. }
  303.  
  304. imageLightboxSelector.src = imageSource;
  305.  
  306. // Switch between <img> and <video> tags based on the new media element
  307. if (currentElement.querySelector("video") && imageLightboxSelector?.tagName === "IMG") {
  308. imageLightboxSelector.parentElement.innerHTML = imageLightboxSelector.parentElement.innerHTML.replace(/^<img/, "<video");
  309.  
  310. setTimeout(() => {
  311. imageLightboxSelector.setAttribute("controls", "");
  312. }, 300);
  313. }
  314. if (currentElement.querySelector("img") && imageLightboxSelector?.tagName === "VIDEO") {
  315. imageLightboxSelector.parentElement.innerHTML = imageLightboxSelector.parentElement.innerHTML.replace(/^<video/, "<img");
  316. }
  317. }
  318. }
  319.  
  320. // =======================================================================================
  321. // Event Listeners
  322. // =======================================================================================
  323.  
  324. function addEventListeners() {
  325. document.addEventListener(
  326. "keydown",
  327. function (event) {
  328. // Navigation in lightbox view
  329. if (document.querySelector(".mx_Dialog_lightbox")) {
  330. if (event.key === "Escape") {
  331. event.stopPropagation();
  332. closeImageBox();
  333. }
  334. }
  335.  
  336. // Navigation in timeline view
  337. // navigate only if the focus is not on message composer and input is empty
  338. const messageComposerInputEmpty = document.querySelector(
  339. ".mx_BasicMessageComposer_input:not(.mx_BasicMessageComposer_inputEmpty)"
  340. );
  341. const isNotInEmptyMessageComposer = document.activeElement !== messageComposerInputEmpty;
  342. if (isNotInEmptyMessageComposer) {
  343. if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
  344. event.preventDefault();
  345. navigateTo("up");
  346. document.activeElement.blur(); // remove focus from message composer
  347. } else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
  348. event.preventDefault();
  349. navigateTo("down");
  350. document.activeElement.blur(); // remove focus from message composer
  351. }
  352. }
  353.  
  354. // navigate only if there is an active media element and remove active media if the key is not a or Space
  355. if (getActiveMedia()) {
  356. if (event.key === " ") {
  357. event.preventDefault();
  358. event.stopPropagation(); // prevent focus on message composer
  359. navigateTo("down");
  360. } else if (event.key === "a") {
  361. event.preventDefault();
  362. event.stopPropagation(); // prevent focus on message composer
  363. navigateTo("up");
  364. } else {
  365. removeActiveMedia();
  366. }
  367. }
  368. },
  369. true
  370. );
  371.  
  372. // add listeners only once
  373. let lightboxListenersAdded = false;
  374. let timelineListenerAdded = false;
  375.  
  376. const observer = new MutationObserver(() => {
  377. const lightbox = document.querySelector(".mx_Dialog_lightbox");
  378.  
  379. if (lightbox && !lightboxListenersAdded) {
  380. // Remove timeline wheel listener when lightbox opens
  381. if (timelineListenerAdded) {
  382. document.removeEventListener("wheel", removeActiveMedia, { passive: false });
  383. timelineListenerAdded = false;
  384. }
  385.  
  386. waitForElement(".mx_ImageView").then((element) => {
  387. // Check if the event listeners are already added
  388. if (!element._listenersAdded) {
  389. element.addEventListener(
  390. "click",
  391. (event) => {
  392. const target = event.target;
  393. // Close lightbox if clicking the background
  394. if (target.matches(".mx_ImageView > .mx_ImageView_image_wrapper > img")) {
  395. closeImageBox();
  396. }
  397. },
  398. true
  399. );
  400.  
  401. element.addEventListener("wheel", getWheelDirection, { passive: false });
  402.  
  403. // Mark the listener as added
  404. element._listenersAdded = true;
  405. }
  406.  
  407. lightboxListenersAdded = true;
  408.  
  409. // set first opened image in lightbox as active element in timeline view
  410. const src = document.querySelector(".mx_ImageView > .mx_ImageView_image_wrapper > img").src;
  411. const img = document.querySelector(`ol.mx_RoomView_MessageList img[src="${src}"]`);
  412. const messageContainer = img.closest("li");
  413. setActiveMedia(messageContainer);
  414. }, true);
  415. // Timeline view mode
  416. } else if (!lightbox && !timelineListenerAdded) {
  417. // remove ActiveMedia in timeline view to allow scrolling
  418. document.addEventListener("wheel", removeActiveMedia, { passive: false });
  419.  
  420. timelineListenerAdded = true;
  421. lightboxListenersAdded = false; // Reset the lightbox listener flag when lightbox is closed
  422. }
  423. });
  424.  
  425. // to detect when the light box is switched on or off
  426. observer.observe(document.body, { childList: true, subtree: true });
  427. }
  428.  
  429. // =======================================================================================
  430. // Main
  431. // =======================================================================================
  432.  
  433. function main() {
  434. console.log(GM_info.script.name, "started");
  435.  
  436. // Add event listeners for navigation
  437. addEventListeners();
  438. }
  439.  
  440. if (
  441. /^element\.[^.]+\.[^.]+$/.test(document.location.host) ||
  442. /^matrixclient\.[^.]+\.[^.]+$/.test(document.location.host) ||
  443. /^app.schildi.chat/.test(document.location.host) ||
  444. /app.element.io/.test(document.location.href)
  445. ) {
  446. main();
  447. }