Twitter/X Layout Modifier

Remove right sidebar, expand middle column, and inject custom vertical image stack with sizing fixes

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Twitter/X Layout Modifier
// @namespace    http://tampermonkey.net/
// @license MIT
// @version      2.0.4
// @description  Remove right sidebar, expand middle column, and inject custom vertical image stack with sizing fixes
// @author       Shuriken777
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  // ── Configuration ────────────────────────────────────────────────────────
  //
  // maxMode: 'height' caps media by viewport-height (good for wide monitors
  //          where you don't want a tall portrait to fill the screen vertically).
  //          'width' caps media by viewport-width (good for narrow windows).
  //
  // The non-leading dimension auto-derives from the media's natural aspect
  // ratio, so nothing is ever cropped — only scaled.

  const CONFIG = {
    maxMode: 'height',     // 'height' or 'width'
    maxHeightVh: 85,       // used when maxMode === 'height'
    maxWidthVw: 60,        // used when maxMode === 'width'
    imageBorder: '1px solid rgba(128,128,128,0.45)',
    borderRadius: '12px',
    gap: 8,
  };

  // ── CSS ──────────────────────────────────────────────────────────────────

  const heightCap = `max-height: ${CONFIG.maxHeightVh}vh;`;
  const widthCap  = `max-width: ${CONFIG.maxWidthVw}vw;`;
  const leadingCap = CONFIG.maxMode === 'height' ? heightCap : widthCap;
  const trailingCap = CONFIG.maxMode === 'height' ? 'max-width: 100%;' : heightCap;

  // ── Stylesheet injection ──
  // At document-start, document.head may not exist yet; fall back to
  // documentElement (works for both modes since HTMLElement is parsed first).
  const style = document.createElement('style');
  style.textContent = `
    /* Hide right sidebar */
    [data-testid="sidebarColumn"] { display: none !important; }

    /* Expand primary column to fill the freed space */
    [data-testid="primaryColumn"] {
      max-width: 100% !important;
      width: 100% !important;
      flex-basis: 100% !important;
    }

    main:has([data-testid="primaryColumn"]) {
      max-width: 100% !important;
      align-items: stretch !important;
    }

    /* Lift the 600px tweet-card constraint inside the primary column.
       Twitter applies max-width:600px on several wrapper divs (class .r-1ye8kvj
       and via inline style). We override at every level to ensure tweet rows
       stretch across the now-wider column. */
    [data-testid="primaryColumn"] > div,
    [data-testid="primaryColumn"] > div > div,
    [data-testid="primaryColumn"] > div > div > div,
    [data-testid="primaryColumn"] [class*="r-1ye8kvj"],
    [data-testid="primaryColumn"] div[style*="max-width: 600px"] {
      max-width: 100% !important;
    }

    /* Tweet <article> has overflow:hidden which clips media spilling beyond
       its original 600px box. Release it. */
    [data-testid="primaryColumn"] article[data-testid="tweet"] {
      overflow: visible !important;
    }

    /* Hide the original Twitter media branch we're replacing (only for
       image-only tweets where we built a custom stack). */
    [data-txl-branch="1"] { display: none !important; }

    /* ── Custom media container (images only) ──
       Twitter posts have 1–4 images. Each image displays at its natural
       aspect ratio, height capped by maxHeightVh (~85vh). Images flow
       left-to-right with flex-wrap: when the next image doesn't fit on
       the current row, it wraps. This gives every image as much room as
       its natural size allows — wide images get wide rows; multiple
       small images line up side-by-side. */

    .txl-media {
      display: flex;
      flex-wrap: wrap;
      gap: ${CONFIG.gap}px;
      align-items: flex-start;
      margin-top: 8px;
      width: 100%;
    }

    /* Each image: capped by viewport-height; width auto-derives from
       natural aspect ratio. max-width:100% prevents an extra-wide image
       from overflowing the column (scales down preserving ratio). */
    .txl-media > img {
      max-height: ${CONFIG.maxHeightVh}vh;
      max-width: 100%;
      width: auto;
      height: auto;
      object-fit: contain;
      border-radius: ${CONFIG.borderRadius};
      border: ${CONFIG.imageBorder};
      display: block;
      cursor: zoom-in;
      background: rgba(0,0,0,0.04);
      flex: 0 0 auto;
    }

    .txl-media > img:hover { opacity: 0.92; }

    .txl-media > img:hover { opacity: 0.92; }

    /* ── In-place video styling ──
       We do NOT move the videoPlayer node — Twitter's React/IntersectionObserver
       attaches autoplay/pause behavior to it, and moving the node breaks
       scroll-pause logic (video pauses on every scroll instead of only when
       it leaves the viewport). Instead we expand the existing tweetPhoto
       host in place and force inner wrappers to fill it. */

    .txl-video-host {
      border-radius: ${CONFIG.borderRadius};
      border: ${CONFIG.imageBorder};
      overflow: hidden;
      background: #000;
      position: relative;
    }

    /* Neutralize intermediate wrappers between host and placementTracking.
       Forcing them to position:static ensures none of them becomes a
       containing block for the absolutely-positioned placementTracking
       (which then resolves against .txl-video-host directly). We don't
       set width/height — absolute children don't need a sized parent. */
    .txl-video-host > div,
    .txl-video-host > div > div,
    .txl-video-host > div > div > div {
      position: static !important;
      max-width: none !important;
      max-height: none !important;
    }

    /* Zero the padding-bottom spacer Twitter uses to encode aspect ratio —
       the host carries aspect-ratio now. Also kill any positioning on it. */
    .txl-video-host div[style*="padding-bottom"] {
      padding-bottom: 0 !important;
      position: static !important;
    }

    /* Overlay the actual video machinery on top of the host. Because the
       host is the only position:relative ancestor (we statified the
       wrappers above), this fills the host exactly. */
    .txl-video-host [data-testid="placementTracking"],
    .txl-video-host [data-testid="videoPlayer"] {
      position: absolute !important;
      top: 0 !important;
      left: 0 !important;
      width: 100% !important;
      height: 100% !important;
      max-width: none !important;
      max-height: none !important;
    }

    .txl-video-host video {
      width: 100% !important;
      height: 100% !important;
      object-fit: contain !important;
      position: absolute !important;
      top: 0 !important;
      left: 0 !important;
      background: #000 !important;
    }
  `;
  (document.head || document.documentElement).appendChild(style);

  // ── Helpers ───────────────────────────────────────────────────────────────

  function toHiRes(src) {
    return src.replace(/([?&]name=)[^&]+/, '$1large');
  }

  // Twitter encodes video aspect ratio as padding-bottom:N% (= height/width).
  // Returns width/height so it can be used directly with CSS aspect-ratio.
  function videoAspectRatio(playerEl) {
    const spacer = playerEl.querySelector('div[style*="padding-bottom"]');
    if (spacer) {
      const pb = spacer.style.paddingBottom;
      if (pb && pb.endsWith('%')) {
        const heightOverWidth = parseFloat(pb) / 100;
        if (heightOverWidth > 0) return 1 / heightOverWidth;
      }
    }
    return 16 / 9;
  }

  // Find the "media branch": the subtree of the tweet that holds all the
  // given image photos and is a structural sibling of EVERY tweetText branch.
  //
  // A tweet article can have multiple tweetText nodes — the main tweet's text
  // plus any quoted tweet's text. We must not return a branch that contains
  // ANY of them, or hiding it would also hide that text.
  //
  // We collect the ancestor chains of all tweetTexts into one set, then walk
  // up from the first photo until we find a parent where some sibling is in
  // that combined chain — that's the fork point between media and text. After
  // picking a candidate, we verify it (a) contains all photos and (b) contains
  // no tweetText. If no such ancestor exists, return null and the caller will
  // leave the tweet alone.
  function findMediaBranchForPhotos(tweet, photos) {
    if (photos.length === 0) return null;

    const tweetTexts = tweet.querySelectorAll('[data-testid="tweetText"]');
    const textAncestors = new Set();
    tweetTexts.forEach(t => {
      let el = t;
      while (el && el !== tweet) {
        textAncestors.add(el);
        el = el.parentElement;
      }
    });

    // A branch is safe to hide only if it contains all photos AND no
    // critical tweet content: text, user identity, engagement metrics,
    // quoted tweets, or video players (we handle videos in-place).
    const SAFETY_SELECTORS = [
      '[data-testid="tweetText"]',
      '[data-testid="User-Name"]',
      '[data-testid="reply"]',
      '[data-testid="like"]',
      '[data-testid="retweet"]',
      '[data-testid="videoPlayer"]',
      '[role="article"]',
    ].join(',');
    const branchIsSafe = (el) =>
      photos.every(p => el.contains(p)) &&
      el.querySelectorAll(SAFETY_SELECTORS).length === 0;

    if (textAncestors.size > 0) {
      let el = photos[0];
      while (el && el !== tweet) {
        const parent = el.parentElement;
        if (parent) {
          const hasSiblingInTextChain = Array.from(parent.children)
            .some(sibling => sibling !== el && textAncestors.has(sibling));
          if (hasSiblingInTextChain && branchIsSafe(el)) return el;
        }
        el = parent;
      }
      // No text-sibling branch is safe — likely the photo and text share an
      // inseparable ancestor (e.g. inside a quoted-tweet card with no media
      // sub-branch). Better to leave Twitter's layout alone than to hide text.
      return null;
    }

    // No tweetText anywhere — pure media tweet. Walk up as high as we
    // can while branchIsSafe holds, so we hide the entire fixed-size
    // wrapper (which Twitter sizes with hardcoded width/height/padding-
    // bottom). Stop before we hit something that holds tweet metadata.
    let el2 = photos[0].parentElement;
    let highestSafe = null;
    while (el2 && el2 !== tweet) {
      if (branchIsSafe(el2)) highestSafe = el2;
      else if (highestSafe) break;
      el2 = el2.parentElement;
    }
    return highestSafe;
  }

  function buildImageStack(imageItems) {
    if (imageItems.length === 0) return null;

    const stack = document.createElement('div');
    stack.className = 'txl-media';

    const seen = new Set();
    imageItems.forEach(({ src, naturalW, naturalH }) => {
      if (seen.has(src)) return;
      seen.add(src);
      const img = document.createElement('img');
      img.src = src;
      img.loading = 'lazy';
      // Setting width/height attributes gives the browser the intrinsic
      // aspect ratio so multi-image cells reserve the right height before
      // the hi-res source loads — prevents a tall row from forcing wide
      // images to letterbox into oversized cells.
      if (naturalW && naturalH) {
        img.width = naturalW;
        img.height = naturalH;
      }
      img.onclick = e => { e.stopPropagation(); window.open(src, '_blank'); };
      stack.appendChild(img);
    });

    if (stack.children.length === 0) return null;
    stack.setAttribute('data-count', String(Math.min(stack.children.length, 4)));
    return stack;
  }

  // Style a video's tweetPhoto in place: expand it to the configured size,
  // clear any inline max-width constraints on ancestors up to the tweet
  // article. The videoPlayer node stays where Twitter put it, so React's
  // autoplay/scroll-pause logic continues to work.
  //
  // soloMedia: when true, the video is the only media in the tweet — size it
  // to fill the column (85vh tall). When false (mixed image+video, or
  // multi-video tweets), leave Twitter's grid sizing alone and only release
  // the inner aspect-ratio padding so the video fills its existing cell.
  function styleVideoInPlace(photo, videoPlayer, soloMedia) {
    const ratio = videoAspectRatio(videoPlayer);
    const tweet = photo.closest('[data-testid="tweet"]');

    photo.classList.add('txl-video-host');
    photo.style.setProperty('aspect-ratio', ratio.toFixed(4), 'important');

    if (!soloMedia) {
      // In a multi-media grid, let Twitter own the cell dimensions — the
      // grid is already 2x2 / row-of-N, and forcing 85vh here would blow up
      // the row height and shove sibling images off the grid.
      return;
    }

    // Clear inline max-width on every ancestor between photo and tweet — Twitter
    // sets these as React-managed inline styles, which our stylesheet's
    // !important can't always beat without an inline override.
    let el = photo;
    while (el && el !== tweet) {
      if (el.style && el.style.maxWidth) {
        el.style.setProperty('max-width', 'none', 'important');
      }
      el = el.parentElement;
    }

    // align-self: flex-start so the photo isn't stretched by ancestor
    // flexbox align-items: stretch (which would override aspect-ratio).
    photo.style.setProperty('align-self', 'flex-start', 'important');
    if (CONFIG.maxMode === 'height') {
      photo.style.setProperty('height', `${CONFIG.maxHeightVh}vh`, 'important');
      photo.style.setProperty('max-width', '100%', 'important');
      photo.style.setProperty('width', 'auto', 'important');
    } else {
      photo.style.setProperty('width', `${CONFIG.maxWidthVw}vw`, 'important');
      photo.style.setProperty('max-height', `${CONFIG.maxHeightVh}vh`, 'important');
      photo.style.setProperty('height', 'auto', 'important');
    }
  }

  // ── Layout fixer ─────────────────────────────────────────────────────────
  // Twitter sets hardcoded pixel widths on ancestor containers via CSS
  // (e.g. width:1050px on the main-content wrapper). max-width:100% in our
  // stylesheet alone cannot override an explicit width:Npx, so we walk up
  // from primaryColumn and force width:100% inline with !important.

  function fixLayout() {
    const primary = document.querySelector('[data-testid="primaryColumn"]');
    if (!primary) return;
    let el = primary;
    while (el && el.tagName !== 'BODY') {
      el.style.setProperty('width', '100%', 'important');
      el.style.setProperty('max-width', '100%', 'important');
      if (el.tagName === 'MAIN') break;
      el = el.parentElement;
    }
  }

  // ── Main processor ────────────────────────────────────────────────────────

  function processTweets() {
    const unprocessed = document.querySelectorAll('[data-testid="tweetPhoto"]:not([data-txl])');
    if (unprocessed.length === 0) return;

    // Group photos by tweet. (We used to skip photos inside div[role="link"]
    // here, but that wrapper is also used on profile pages to make the entire
    // tweet card clickable — skipping it dropped every repost's media. The
    // safety check now lives in findMediaBranchForPhotos, which returns null
    // if no media branch can be hidden without also hiding tweet text.)
    const tweetMap = new Map();
    unprocessed.forEach(photo => {
      const tweet = photo.closest('[data-testid="tweet"]');
      if (!tweet) return;
      // Don't mark as processed yet if there's no img and no videoPlayer —
      // Twitter lazy-loads the image src; marking it now would prevent retries
      // once the content actually loads.
      const hasContent = photo.querySelector('img') ||
                         photo.querySelector('[data-testid="videoPlayer"]');
      if (!hasContent) return;
      photo.setAttribute('data-txl', '1');
      if (!tweetMap.has(tweet)) tweetMap.set(tweet, []);
      tweetMap.get(tweet).push(photo);
    });

    tweetMap.forEach((photos, tweet) => {
      const videos = [];
      const images = [];

      photos.forEach(photo => {
        const vp = photo.querySelector('[data-testid="videoPlayer"]');
        if (vp) {
          videos.push({ photo, videoPlayer: vp });
          return;
        }
        const img = photo.querySelector('img');
        if (img) {
          images.push({
            photo,
            src: toHiRes(img.src),
            naturalW: img.naturalWidth || 0,
            naturalH: img.naturalHeight || 0,
          });
        }
      });

      // Videos: in-place expansion. soloMedia is true only when the video is
      // the only piece of media in this tweet — for mixed grids (multi-video
      // or image+video), we leave Twitter's grid sizing intact and only
      // upgrade the videoPlayer styling so it fills its existing cell.
      const soloMedia = photos.length === 1;
      videos.forEach(({ photo, videoPlayer }) => styleVideoInPlace(photo, videoPlayer, soloMedia));

      // Images: replace original media branch with a hi-res stack — but
      // ONLY if the tweet has no videos. Mixing the two would require
      // hiding the branch (which contains the video), which would hide
      // the video too.
      if (images.length === 0) return;

      if (videos.length === 0) {
        const imagePhotos = images.map(i => i.photo);
        const mediaBranch = findMediaBranchForPhotos(tweet, imagePhotos);
        if (!mediaBranch || mediaBranch.hasAttribute('data-txl-branch')) return;
        // Don't accidentally hide the entire tweet — the branch should
        // be a strict descendant of the tweet article.
        if (mediaBranch === tweet) return;
        mediaBranch.setAttribute('data-txl-branch', '1');
        const stack = buildImageStack(images);
        if (stack) mediaBranch.insertAdjacentElement('afterend', stack);
      } else {
        // Mixed image+video tweet: upgrade image sources to hi-res in place,
        // leave layout alone so the video continues to work.
        images.forEach(({ photo, src }) => {
          const img = photo.querySelector('img');
          if (img && img.src !== src) img.src = src;
        });
      }
    });
  }

  // ── Observer & init ───────────────────────────────────────────────────────
  // At document-start, document.body doesn't exist yet. Wait for it, then
  // attach the MutationObserver and run an immediate first pass so the
  // first tweets to mount are processed without the 800ms idle delay.

  let debounce = null;
  const tick = () => { fixLayout(); processTweets(); };
  const observer = new MutationObserver(() => {
    clearTimeout(debounce);
    debounce = setTimeout(tick, 150);
  });

  function startObserving() {
    observer.observe(document.body, { childList: true, subtree: true });
    tick();
    // Fast retry loop covers the first few seconds where React is mounting
    // tweets in chunks — the debounced observer can miss the very first one.
    let burstsLeft = 25; // 25 * 200ms = 5s of fast polling
    const burst = setInterval(() => {
      tick();
      if (--burstsLeft <= 0) clearInterval(burst);
    }, 200);
    setInterval(tick, 5000);
  }

  if (document.body) {
    startObserving();
  } else {
    // documentElement is available immediately at document-start; watch it
    // until <body> appears, then hand off to the real observer.
    const bootstrap = new MutationObserver(() => {
      if (document.body) {
        bootstrap.disconnect();
        startObserving();
      }
    });
    bootstrap.observe(document.documentElement, { childList: true });
  }

})();