i2psnark/res/js/textView.js

/**
 * @module textView
 * @file textView.js - Inline text viewer for I2PSnark file browser.
 * @description Provides an inline text viewing overlay for supported text file types
 * (.asc, .bat, .css, .ini, .js, .json, .md5, .nfo, .sh, .srt, .txt, .url, and CSV).
 * Supports line-numbered display for code-like files, iframe-aware fullscreen mode,
 * response caching, scroll locking, and open-in-new-tab links.
 * @author dr|z3d
 * @license AGPL3 or later
 */

document.addEventListener("DOMContentLoaded", function () {

  /**
   * @type {string[]}
   * @description CSS selectors for links to supported text file types. Used to identify
   * clickable elements that should open in the inline text viewer.
   */
  const viewLinks = [
    'td.fileIcon.text>a',
    'td.snarkFileName>a[href$=".asc"]',
    'td.snarkFileName>a[href$=".bat"]',
    'td.snarkFileName>a[href$=".css"]',
    'td.snarkFileName>a[href$=".ini"]',
    'td.snarkFileName>a[href$=".js"]',
    'td.snarkFileName>a[href$=".json"]',
    'td.snarkFileName>a[href$=".md5"]',
    'td.snarkFileName>a[href$=".nfo"]',
    'td.snarkFileName>a[href$=".sh"]',
    'td.snarkFileName>a[href$=".srt"]',
    'td.snarkFileName>a[href$=".txt"]',
    'td.snarkFileName>a[href$=".url"]'
  ];

  const doc = document;
  const parentDoc = window.parent.document;
  const isIframed = doc.documentElement.classList.contains("iframed") || window.top !== window.self;
  const snarkFileNameLinks = doc.querySelectorAll(":where(" + viewLinks.join(",") + ")");
  /**
   * @type {Set<string>}
   * @description Set of supported file extensions for the inline text viewer.
   */
  const supportedFileTypes = new Set(["asc", "bat", "css", "csv", "ini", "js", "json", "md5", "nfo", "txt", "sh", "srt", "url"]);

  /**
   * @type {Set<string>}
   * @description Set of file extensions that should display with line numbers.
   */
  const numberedFileExts = new Set(["bat", "css", "ini", "js", "sh"]);

  /**
   * @type {string}
   * @description URL path to the text viewer stylesheet.
   */
  const cssHref = "/i2psnark/.res/textView.css";

  /**
   * @type {?HTMLElement}
   * @description The text view content container element.
   */
  const textviewContent = doc.getElementById("textview-content");

  /**
   * @type {Map<string, string>}
   * @description Cache mapping fetched file URLs to their text content.
   */
  const responseCache = new Map();

  /**
   * @type {DOMParser}
   * @description Reusable DOMParser for escaping HTML in fetched content.
   */
  const parser = new DOMParser();

  /**
   * @type {boolean}
   * @description Whether event listeners have already been attached.
   */
  let listenersActive = false;

  /** @type {?HTMLElement} */
  let viewerWrapper;

  /** @type {?HTMLElement} */
  let viewerContent;

  /** @type {?HTMLElement} */
  let viewerFilename;

  const fragment = doc.createDocumentFragment();
  snarkFileNameLinks.forEach(link => {
    if (link.closest("td.snarkFileName")) {
      const newTabSpan = doc.createElement("span");
      newTabSpan.className = "newtab";
      const newTabLink = doc.createElement("a");
      newTabLink.href = link.href;
      newTabLink.target = "_blank";
      newTabLink.title = "Open in new tab";
      newTabSpan.appendChild(newTabLink);
      fragment.appendChild(newTabSpan);
      link.parentNode.insertBefore(newTabSpan, link.nextSibling);
    }
  });

  /**
   * @function loadCSS
   * @description Dynamically loads a CSS stylesheet by inserting a <link> element before
   * the #snarkTheme element in the document head.
   * @param {string} href - The URL of the CSS file to load.
   * @returns {void}
   */
  function loadCSS(href) {
    const snarkTheme = doc.getElementById("snarkTheme");
    if (!snarkTheme) {return;}
    const css = doc.createElement("link");
    css.rel = "stylesheet";
    css.href = href;
    snarkTheme.parentNode.insertBefore(css, snarkTheme);
  }

  /**
   * @function createTextViewer
   * @description Creates the text viewer DOM structure (wrapper, content, filename elements)
   * if it doesn't already exist. Loads the viewer CSS and appends the wrapper to the body.
   * @returns {?Object} An object with viewerWrapper, viewerContent, and viewerFilename references, or undefined if already created.
   */
  function createTextViewer() {
    if (!viewerWrapper) {
      loadCSS(cssHref);
      viewerWrapper = doc.createElement("div");
      viewerContent = doc.createElement("div");
      viewerFilename = doc.createElement("div");
      viewerWrapper.id = "textview";
      viewerWrapper.hidden = true;
      viewerContent.id = "textview-content";
      viewerFilename.id = "viewerFilename";
      viewerContent.appendChild(viewerFilename);
      viewerWrapper.appendChild(viewerContent);
      doc.body.appendChild(viewerWrapper);
      return { viewerWrapper, viewerContent, viewerFilename };
    }
  }

  ({ viewerWrapper, viewerContent, viewerFilename } = createTextViewer());

  /**
   * @function displayWithLineNumbers
   * @description Converts plain text into an ordered list with each line as a list item,
   * providing numbered line display for code-like files.
   * @param {string} text - The raw text content to format.
   * @returns {string} HTML string with line numbers as an ordered list.
   */
  const displayWithLineNumbers = text => {
    const lines = text.split("\n").map(line => `<li>${line}</li>`);
    return `<ol>${lines.join("")}</ol>`;
  };

  /**
   * @function displayText
   * @description Fetches and displays the content of a text file in the viewer. Uses the
   * response cache to avoid redundant fetches. Shows the file icon and filename in the viewer header.
   * @param {HTMLElement} link - The clicked file link element.
   * @param {string} fileName - The decoded filename to display.
   * @param {string} fileExt - The lowercase file extension.
   * @param {string} linkHref - The URL to fetch the file content from.
   * @returns {void}
   */
  const displayText = (link, fileName, fileExt, linkHref) => {
    if (responseCache.has(linkHref)) {
      renderContent(fileName, fileExt, responseCache.get(linkHref));
      return;
    }

    viewerFilename.innerHTML = "";
    const fileIconTd = link.closest("tr").querySelector("td.fileIcon");
    if (fileIconTd) {
      const fileIcon = fileIconTd.querySelector("img");
      if (fileIcon) { viewerFilename.prepend(fileIcon.cloneNode(true)); }
    }

    fetch(linkHref).then(response => response.text()).then(data => {
      if (responseCache.size >= 50) {responseCache.delete(responseCache.keys().next().value);}
      responseCache.set(linkHref, data);
      renderContent(fileName, fileExt, data);
    }).catch(() => {});

    const fileNameSpan = doc.createElement("span");
    fileNameSpan.textContent = fileName;
    viewerFilename.appendChild(fileNameSpan);
  };

  /**
   * @function resetScrollPosition
   * @description Resets the text view content scroll position to the top. Retries after
   * a short delay if the content element is not yet rendered.
   * @returns {void}
   */
  const resetScrollPosition = () => {
    const txtContent = doc.getElementById("textview-content");
    if (txtContent && txtContent.innerHTML !== "") { txtContent.scrollTop = 0; }
  }

  /**
   * @function preventScroll
   * @description Adds or removes scroll prevention on the given elements by toggling
   * overflow:hidden, which locks scrolling while the text viewer is open.
   * @param {Element[]} elements - Elements to lock/unlock scrolling on.
   * @param {boolean} prevent - Whether to enable (true) or disable (false) scroll prevention.
   * @returns {void}
   */
  function preventScroll(elements, prevent) {
    elements.forEach(element => {
      if (element) {element.style.overflow = prevent ? "hidden" : "";}
    });
  }

  /**
   * @function renderContent
   * @description Renders fetched text content into the viewer. Applies pre-formatted styling
   * for non-plain-text files, HTML-escapes the content, displays with or without line numbers
   * based on file type, and shows the viewer wrapper.
   * @param {string} fileName - The filename to display in the viewer header.
   * @param {string} fileExt - The file extension determining display format.
   * @param {string} data - The raw text content to render.
   * @returns {void}
   */
  const renderContent = (fileName, fileExt, data) => {
    if (fileExt !== "txt" && fileExt !== "srt") { viewerContent.classList.add("pre"); }
    const escaped = parser.parseFromString(data, "text/html").body.textContent || data;
    const needsHardSpaces = numberedFileExts.has(fileExt) || viewerContent.classList.contains("pre");
    const encoded = needsHardSpaces ? escaped.replace(/ /g, "\u00A0") : escaped;
    viewerContent.innerHTML = numberedFileExts.has(fileExt) ? displayWithLineNumbers(encoded) : encoded;
    viewerContent.classList.toggle("lines", numberedFileExts.has(fileExt));
    viewerContent.insertBefore(viewerFilename, viewerContent.firstChild);
    viewerWrapper.hidden = false;
    requestAnimationFrame(resetScrollPosition);
    preventScroll([doc.documentElement, doc.body], true);
  };

  /**
   * @function addListeners
   * @description Registers a delegated click handler that opens the text viewer for
   * file links, and click handlers on the viewer for closing it. Prevents duplicate
   * listener attachment.
   * @returns {void}
   */
  const addListeners = () => {
    if (listenersActive) { return; }
    doc.addEventListener("click", event => {
      const link = event.target.closest(viewLinks.join(","));
      if (!link) { return; }
      if (event.target.classList.contains("newtab")) { return; }
      event.preventDefault();
      const fileIconLink = link.closest("tr").querySelector("td.fileIcon > a");
      if (fileIconLink) {
        doc.documentElement.classList.add("textviewer");
        if (isIframed && !parentDoc.documentElement.classList.contains("fullscreen")) {
          parentDoc.documentElement.classList.add("textviewer", "fullscreen");
        }
        const fileName = decodeURIComponent(fileIconLink.href.split("/").pop());
        const fileExt = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
        if (supportedFileTypes.has(fileExt)) { displayText(fileIconLink, fileName, fileExt, fileIconLink.href); }
      }
    });

    viewerContent.addEventListener("click", event => event.stopPropagation());
    viewerWrapper.addEventListener("click", () => {
      viewerWrapper.hidden = true;
      doc.documentElement.classList.remove("textviewer", "fullscreen");
      preventScroll([doc.documentElement, doc.body], false);
      if (isIframed) {
        parentDoc.documentElement.classList.remove("textviewer", "fullscreen");
        preventScroll([parentDoc.documentElement, parentDoc.body], false);
      }
    });
    listenersActive = true;
  };

  addListeners();

});