Greasy Fork 支持 简体中文。

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-02-26 提交的版本,檢視 最新版本

  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 20250226
  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) {
  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. // DEBUG
  150. // console.log("nextActiveElement: ", nextActiveElement);
  151. setActiveElement(nextActiveElement);
  152. }
  153.  
  154. if (document.querySelector(".mx_Dialog_lightbox")) {
  155. replaceContentInLightbox();
  156. }
  157. }
  158.  
  159. /**
  160. * Sets an element as the active one and scrolls it into view.
  161. * @param {Element} nextActiveElement - DOM element to set active.
  162. */
  163. function setActiveElement(nextActiveElement) {
  164. if (nextActiveElement) {
  165. removeActiveElement();
  166.  
  167. nextActiveElement.classList.add(activeElementClass);
  168. nextActiveElement.scrollIntoView({
  169. block: isLastElement(nextActiveElement) ? "end" : "center",
  170. behavior: "auto",
  171. });
  172. } else {
  173. console.error("setActiveElement: nextActiveElement not found");
  174. }
  175. }
  176.  
  177. /**
  178. * Removes the "active-element" class from the currently active element.
  179. *
  180. * This function searches for an element with the class name stored in the
  181. * variable `activeElementClass` and removes the "active-element" class from it.
  182. * If no such element is found, the function does nothing.
  183. */
  184. function removeActiveElement() {
  185. const activeElement = document.querySelector(`.${activeElementClass}`); // Find the currently active element
  186. if (activeElement) {
  187. // console.error("removeActiveElement");
  188. activeElement.classList.remove("active-element"); // Remove the active class
  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. setActiveElement(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 = document.querySelector(`.${activeElementClass}`);
  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. // if in lightbox
  312. if (document.querySelector(".mx_Dialog_lightbox")) {
  313. if (event.key === "Escape") {
  314. event.stopPropagation();
  315. closeImageBox();
  316. }
  317. }
  318. // Navigation
  319. // only if the focus is not on message composer
  320. const messageComposerInput = document.querySelector(
  321. ".mx_BasicMessageComposer_input.mx_BasicMessageComposer_input_shouldShowPillAvatar:not(.mx_BasicMessageComposer_inputEmpty)"
  322. );
  323. const isNotInMessageComposer = document.activeElement !== messageComposerInput;
  324.  
  325. if (isNotInMessageComposer) {
  326. if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
  327. event.preventDefault();
  328. navigateTo("up");
  329. } else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
  330. event.preventDefault();
  331. navigateTo("down");
  332. }
  333. }
  334. },
  335. true
  336. );
  337.  
  338. // add listeners only once
  339. let lightboxListenersAdded = false;
  340. let timelineListenerAdded = false;
  341.  
  342. const observer = new MutationObserver(() => {
  343. const lightbox = document.querySelector(".mx_Dialog_lightbox");
  344.  
  345. if (lightbox && !lightboxListenersAdded) {
  346. // Remove timeline wheel listener when lightbox opens
  347. if (timelineListenerAdded) {
  348. document.removeEventListener("wheel", removeActiveElement, { passive: false });
  349. timelineListenerAdded = false;
  350. }
  351.  
  352. waitForElement(".mx_ImageView").then((element) => {
  353. // Check if the event listeners are already added
  354. if (!element._listenersAdded) {
  355. element.addEventListener(
  356. "click",
  357. (event) => {
  358. const target = event.target;
  359. // Close lightbox if clicking the background
  360. if (target.matches(".mx_ImageView > .mx_ImageView_image_wrapper > img")) {
  361. closeImageBox();
  362. }
  363. },
  364. true
  365. );
  366.  
  367. element.addEventListener("wheel", getWheelDirection, { passive: false });
  368. document.addEventListener(
  369. "keydown",
  370. (event) => {
  371. if (event.key === " ") {
  372. event.preventDefault();
  373. console.log("Space pressed");
  374. navigateTo("down");
  375. }
  376. },
  377. true
  378. );
  379.  
  380. // Mark the listener as added
  381. element._listenersAdded = true;
  382. }
  383.  
  384. lightboxListenersAdded = true;
  385.  
  386. // set first opened image in lightbox as active element in timeline view
  387. const src = document.querySelector(".mx_ImageView > .mx_ImageView_image_wrapper > img").src;
  388. const img = document.querySelector(`ol.mx_RoomView_MessageList img[src="${src}"]`);
  389. const messageContainer = img.closest("li");
  390. setActiveElement(messageContainer);
  391. }, true);
  392. // Timeline view mode
  393. } else if (!lightbox && !timelineListenerAdded) {
  394. // remove ActiveElement in timeline view to allow scrolling
  395. document.addEventListener("wheel", removeActiveElement, { passive: false });
  396.  
  397. timelineListenerAdded = true;
  398. lightboxListenersAdded = false; // Reset the lightbox listener flag when lightbox is closed
  399. }
  400. });
  401.  
  402. // to detect when the light box is switched on or off
  403. observer.observe(document.body, { childList: true, subtree: true });
  404. }
  405.  
  406. // =======================================================================================
  407. // Main
  408. // =======================================================================================
  409.  
  410. function main() {
  411. console.log(GM_info.script.name, "started");
  412.  
  413. // Add event listeners for navigation
  414. addEventListeners();
  415. }
  416.  
  417. if (
  418. /^element\.[^.]+\.[^.]+$/.test(document.location.host) ||
  419. /^matrixclient\.[^.]+\.[^.]+$/.test(document.location.host) ||
  420. /^app.schildi.chat/.test(document.location.host) ||
  421. /app.element.io/.test(document.location.href)
  422. ) {
  423. main();
  424. }