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

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