Internet Roadtrip - Map Picture in Picture

Allows you to open the minimap in neal.fun/internet-roadtrip as a Picture in Picture window (Chromium only!)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Internet Roadtrip - Map Picture in Picture
// @description  Allows you to open the minimap in neal.fun/internet-roadtrip as a Picture in Picture window (Chromium only!)
// @namespace    me.netux.site/user-scripts/internet-roadtrip/picture-in-picture
// @version      1.2.2
// @author       netux
// @license      MIT
// @match        https://neal.fun/internet-roadtrip/*
// @icon         https://neal.fun/favicons/internet-roadtrip.png
// @run-at       document-start
// @grant        unsafeWindow
// @grant        GM_addStyle
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// @require      https://cdn.jsdelivr.net/npm/[email protected]
// ==/UserScript==

/* globals IRF */

(async () => {
  if (!window.documentPictureInPicture) {
    const wantsToSeeCompatibleBrowsers = confirm([
      `Thanks for installing ${GM.info.script.name}!`,
      `Unfortunately, your browser doesn't support the Document Picture in Picture API, so this userscript won't work for you :(`,
      '',
      `Click OK to open a list of compatible browsers, or Cancel to proceed into Internet Roadtrip.`,
      `This userscript will disable itself now.`
    ].join('\n'));

    if (wantsToSeeCompatibleBrowsers) {
      window.open('https://caniuse.com/mdn-api_documentpictureinpicture');
    }

    return;
  }

  const LOG_PREFIX = '[MPiP]';
  const CSS_PREFIX = `mpip-`;
  const cssClass = (... names) => names.map((name) => `${CSS_PREFIX}${name}`).join(' ');
  const cssProp = (name) => `--${CSS_PREFIX}${name}`;

  function isStylesheetCrossOrigin(styleSheet) {
    const hrefHostname = styleSheet.href ? new URL(styleSheet.href).hostname : null;
    if (hrefHostname && hrefHostname !== window.location.hostname) {
      return true;
    }

    try {
      styleSheet.rules;
    } catch (error) {
      if (
        error instanceof DOMException &&
        error.code === DOMException.SECURITY_ERR
      ) {
        return true;
      }
    }

    return false;
  }

  /*
   * Create a StyleSheet that is the amalgamation of all rules that could be related to the minimap.
   */
  function createPiPStyleSheet(pipWindow) {
    const styleSheet = pipWindow.eval(`new CSSStyleSheet()`); // the stylesheet has to be created on the PiP window, otherwise it gets automatically adopted by the parent window.
    pipWindow.document.adoptedStyleSheets.push(styleSheet);

    const isRuleSelectorMatching = (rule) => ['#mini-map', '.maplibregl-', '.mpip-'].some((part) => rule.selectorText.includes(part));

    const rulesAlreadySeen = new Set();

    function ruleMatchesDeep(rule) {
      if (rulesAlreadySeen.has(rule)) {
        return false;
      }
      rulesAlreadySeen.add(rule);

      if (rule instanceof CSSStyleRule) {
        if (isRuleSelectorMatching(rule)) {
          return true;
        }

        let matches = false;

        for (const innerRule of (rule.cssRules ?? [])) {
          if (ruleMatchesDeep(rule)) {
            break;
          }
        }

        return matches;
      } else if (rule instanceof CSSMediaRule) {
        if (rule.conditionText.includes('display-mode: picture-in-picture')) {
          return true;
        }

        let matches = false;

        for (const innerRule of (rule.cssRules ?? [])) {
          if (ruleMatchesDeep(rule)) {
            break;
          }
        }

        return matches;
      } else if (
        rule instanceof CSSFontFaceRule ||
        rule instanceof CSSKeyframeRule
      ) {
        return true;
      }

      return false;
    }

    function insertCssRuleIfMatching(rule) {
      if (!ruleMatchesDeep(rule)) {
        return;
      }

      styleSheet.insertRule(rule.cssText);
    }

    // Copy from page
    for (const styleSheet of document.styleSheets) {
      if (isStylesheetCrossOrigin(styleSheet)) {
        continue;
      }

      for (const rule of styleSheet.rules) {
        insertCssRuleIfMatching(rule);
      }
    }

    styleSheet.insertRule(`
    body {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      margin: 0;
      overflow: hidden;
    }
    `);
    styleSheet.insertRule(`
    #mini-map {
      width: inherit !important;
      height: inherit !important;
    }
    `);
  }

  /** @type {Map<HTMLElement, Array<Parameters<typeof Element["addEventListener"]>>>} */
  const storedEventListenersToReplicateInPiPWindow = new Map();
  {
    const waitForElementsToStoreEventListenersFor = IRF.dom.map
      .then((mapContainerEl) => [window, document, document.body, mapContainerEl]);

    {
      const addEventListenerProxyConfig = {
        apply(ogAddEventListener, thisArg, args) {
          waitForElementsToStoreEventListenersFor.then((elementsToStoreEventListenersFor) => {
            if (!elementsToStoreEventListenersFor.includes(thisArg)) {
              return;
            }

            const eventListenerArgsList = storedEventListenersToReplicateInPiPWindow.get(thisArg) || [];
            eventListenerArgsList.push(args);
            storedEventListenersToReplicateInPiPWindow.set(thisArg, eventListenerArgsList);
          });

          return ogAddEventListener.apply(thisArg, args);
        }
      };

      window.addEventListener = new Proxy(window.addEventListener, addEventListenerProxyConfig);
      document.addEventListener = new Proxy(document.addEventListener, addEventListenerProxyConfig);
      Element.prototype.addEventListener = new Proxy(Element.prototype.addEventListener, addEventListenerProxyConfig);
    }
  }

  async function webpackPatch(patchConfigs) {
    const WEBPACK_MODULE_INITIALIZER_FUNCTION_REGEXP = /function\s*\((?<argsList>.+?)\)\s*{(?<body>(?:\n|.)*)}/;

    return new Promise((resolve) => {
      const patchesToDo = new Set(patchConfigs);

      function performPatches(modulesInitializersOrWhatever) {
        for (const key in modulesInitializersOrWhatever) {
          if (typeof modulesInitializersOrWhatever[key] !== 'function') {
            continue;
          }

          const moduleInitializerFnStr = modulesInitializersOrWhatever[key].toString();

          const matchingPatches = [];
          for (const patchConfig of patchesToDo) {
            if (!moduleInitializerFnStr.includes(patchConfig.needle)) {
              continue;
            }

            matchingPatches.push(patchConfig);
          }

          if (matchingPatches.length <= 0) {
            continue;
          }

          const {
            argsList: moduleInitializerArgsListStr,
            body: moduleInitializerFnBodyStr
          } = moduleInitializerFnStr.match(WEBPACK_MODULE_INITIALIZER_FUNCTION_REGEXP).groups;

          const moduleInitializerArgs = moduleInitializerArgsListStr.split(',').map((a) => a.trim());

          let patchedModuleInitializerFnBodyStr = moduleInitializerFnBodyStr;
          for (const patchConfig of matchingPatches) {
            patchedModuleInitializerFnBodyStr = patchedModuleInitializerFnBodyStr
              .replace(patchConfig.match, patchConfig.replacement)

            patchesToDo.delete(patchConfig);
          }

          modulesInitializersOrWhatever[key] = new Function(
            ... moduleInitializerArgs,
            patchedModuleInitializerFnBodyStr
          );
        }

        return modulesInitializersOrWhatever;
      }

      function hookWebpackJsonpPush() {
        let push = unsafeWindow.webpackJsonp.push;
        Object.defineProperty(unsafeWindow.webpackJsonp, 'push', {
          get() {
            return push;
          },

          set(value) {
            const prevPush = push;
            push = value;

            if (prevPush !== push && typeof push === 'function' && !push._mpipHooked) {
              push = new Proxy(push, {
                apply(ogPush, thisArg, args) {
                  const [[moduleIdsOrWhatever, modulesInitializersOrWhatever]] = args;

                  let newModulesInitializersOrWhatever = modulesInitializersOrWhatever;
                  if (patchesToDo.size > 0) {
                    newModulesInitializersOrWhatever = performPatches(modulesInitializersOrWhatever);

                    if (patchesToDo.size === 0) {
                      resolve();
                    }
                  }

                  return ogPush.apply(thisArg, args);
                }
              });

              push._mpipHooked = true;
            }
          }
        });
      }

      function hookWebpackJsonp() {
        let webpackJsonp = unsafeWindow.webpackJsonp;
        Object.defineProperty(unsafeWindow, 'webpackJsonp', {
          get() {
            return webpackJsonp;
          },

          set(value) {
            const prevWebpackJsonp = webpackJsonp;
            webpackJsonp = value;

            if (prevWebpackJsonp !== webpackJsonp && typeof webpackJsonp === 'object') {
              hookWebpackJsonpPush();
            }
          }
        });
      }

      if (unsafeWindow.webpackJsonp != null) {
        if (!(unsafeWindow.webpackJsonp.push?.toString().includes('[native code]') ?? true)) {
          console.warn(LOG_PREFIX, 'window.webpackJsonp.push() already initialized. Webpack module patches may not work!');
        }

        hookWebpackJsonpPush();
      } else {
        hookWebpackJsonp();
      }
    });
  }

  webpackPatch([
    {
      // Patch maplibregl's isPointableEvent() util[^1] to not use `instanceof` for checking if an event
      // is a MouseEvent or WheelEvent when handling DOM events[^2].
      //
      // This was a problem because the PiP window has different MouseEvent/WheelEvent objects on its window,
      // which made this test not pass.
      //
      // Fixes scroll to zoom not working in the PiP window.
      //
      // [1]: https://github.com/maplibre/maplibre-gl-js/blob/v5.3.1/src/util/util.ts#L1066-L1068
      // [2]: https://github.com/maplibre/maplibre-gl-js/blob/v5.3.1/src/ui/handler_manager.ts#L390
      needle: 'CooperativeGesturesHandler.WindowsHelpText',
      match: /([\w$]+)\s*instanceof\s*MouseEvent\s*\|\|\s*\1\s*instanceof\s*WheelEvent/,
      replacement: `'clientX' in $1`,
    }
  ])
    .then(() => {
      console.info(LOG_PREFIX, 'Successfully patched Webpack Maplibre module');
    })
    .catch((error) => {
      console.error(LOG_PREFIX, 'Could not perform Webpack Maplibre module patches:', error);
    });

  const settings = {
    keepMarkerCentered: true,
    keepMarkerFacingDirectionOfTravel: true,
    autoContractSiteMinimap: true
  };
  Object.assign(settings, Object.fromEntries(
    await Promise.all(
      Object.keys(settings)
        .map((key) =>
          GM.getValue(key, /* defaultValue: */ settings[key])
            .then((value) => [key, value])
        )
    )
  ));

  async function saveSettings() {
    for (const key in settings) {
      await GM.setValue(key, settings[key]);
    }
  }

  let pipWindow;
  let placeholderMapEl;
  let minimapExpandStateBeforePiP;

  async function openPiP() {
    const mapContainerEl = await IRF.dom.map;
    const mapVDOM = await IRF.vdom.map;
    const minimapEl = mapVDOM.data.map.getContainer();

    if (pipWindow) {
      await closePiP();
    }

    pipWindow = await documentPictureInPicture.requestWindow({
      width: 300,
      height: 300
    });
    createPiPStyleSheet(pipWindow);

    if (settings.autoContractSiteMinimap) {
      minimapExpandStateBeforePiP = mapVDOM.state.isExpanded;
      mapVDOM.state.isExpanded = false;
    }

    placeholderMapEl = minimapEl.cloneNode(/* deep: */ false);
    placeholderMapEl.classList.add(cssClass('placeholder-map'));
    minimapEl.insertAdjacentElement('afterend', placeholderMapEl);

    {
      const placeholderMapInstructionalTextEl = document.createElement('span');
      placeholderMapInstructionalTextEl.textContent = 'Minimap open in Picture in Picture';

      const placeholderMapCloseButtonEl = document.createElement('button');
      placeholderMapCloseButtonEl.textContent = 'Bring back here';
      placeholderMapCloseButtonEl.classList.add(cssClass('placeholder-map__bring-back-button'));
      placeholderMapCloseButtonEl.addEventListener('click', closePiP);

      placeholderMapEl.append(
        placeholderMapInstructionalTextEl,
        placeholderMapCloseButtonEl
      );
    }

    document.body.classList.toggle(cssClass('is-in-pip'), true);

    pipWindow.document.body.append(minimapEl);

    const containerVDOM = await IRF.vdom.container;
    applyEnabledPiPOnlyMinimapTransforms(mapVDOM.data.map, {
      coords: containerVDOM.data.currentCoords,
      heading: containerVDOM.data.currentHeading
    });

    pipWindow.addEventListener('pagehide', closePiP);

    for (const element of storedEventListenersToReplicateInPiPWindow.keys()) {
      let targetElement;

      switch (element) {
        case window: {
          targetElement = pipWindow;
          break;
        }
        case document: {
          targetElement = pipWindow.document;
          break;
        }
        case document.body:
        case mapContainerEl: {
          targetElement = pipWindow.document.body;
          break;
        }
        default: {
          continue;
        }
      }

      for (const addEventListenerArgs of storedEventListenersToReplicateInPiPWindow.get(element)) {
        targetElement.addEventListener(... addEventListenerArgs);
      }
    }
  }

  async function closePiP() {
    const mapVDOM = await IRF.vdom.map;
    const minimapEl = mapVDOM.data.map.getContainer();

    document.body.classList.toggle(cssClass('is-in-pip'), false);

    if (placeholderMapEl) {
      placeholderMapEl.insertAdjacentElement('beforebegin', minimapEl);
      placeholderMapEl.remove();
    }

    if (settings.autoContractSiteMinimap && minimapExpandStateBeforePiP) {
      mapVDOM.state.isExpanded = minimapExpandStateBeforePiP;
    }

    pipWindow?.close();

    pipWindow = null;
  }

  GM.registerMenuCommand('Open Minimap Picture in Picture', openPiP);

  {
    const tab = IRF.ui.panel.createTabFor(
      {
        ... GM.info,
        script: {
          ... GM.info.script,
          name: GM.info.script.name.replace('Internet Roadtrip - ', '')
        }
      },
      {
        tabName: 'Map Picture in Picture',
        style: `
        .${cssClass('settings-tab-content')} {
          & *, *::before, *::after {
            box-sizing: border-box;
          }

          & .${cssClass('field-group')} {
            margin-block: 1rem;
            gap: 0.25rem;
            display: flex;
            align-items: center;
            justify-content: space-between;

            & input:is(:not([type]), [type="text"], [type="number"]) {
              --padding-inline: 0.5rem;

              width: calc(100% - 2 * var(--padding-inline));
              min-height: 1.5rem;
              margin: 0;
              padding-inline: var(--padding-inline);
              color: white;
              background: transparent;
              border: 1px solid #848e95;
              font-size: 100%;
              border-radius: 5rem;
            }
          }
        }
        `,
        className: cssClass('settings-tab-content')
      }
    );

    function makeFieldGroup({ id, label }, renderInput) {
      const fieldGroupEl = document.createElement('div');
      fieldGroupEl.className = cssClass('field-group');

      const labelEl = document.createElement('label');
      labelEl.textContent = label;

      const inputEl = renderInput({ id });

      fieldGroupEl.append(
        labelEl,
        inputEl
      )

      return fieldGroupEl;
    }

    tab.container.append(
      makeFieldGroup({ id: `${CSS_PREFIX}keep-marker-centered-toggle`, label: 'Keep Map Marker Centered while in PiP' }, () => {
        const inputEl = document.createElement('input');
        inputEl.type = 'checkbox';
        inputEl.className = IRF.ui.panel.styles.toggle;
        inputEl.checked = settings.keepMarkerCentered;

        inputEl.addEventListener('change', async () => {
          settings.keepMarkerCentered = inputEl.checked;
          await saveSettings();
        });

        return inputEl;
      }),
      makeFieldGroup({ id: `${CSS_PREFIX}keep-marker-facing-direction-of-travel-toggle`, label: 'Keep Map Marker Facing the Direction of Travel while in PiP' }, () => {
        const inputEl = document.createElement('input');
        inputEl.type = 'checkbox';
        inputEl.className = IRF.ui.panel.styles.toggle;
        inputEl.checked = settings.keepMarkerFacingDirectionOfTravel;

        inputEl.addEventListener('change', async () => {
          settings.keepMarkerFacingDirectionOfTravel = inputEl.checked;
          await saveSettings();
        });

        return inputEl;
      }),
      makeFieldGroup({ id: `${CSS_PREFIX}auto-contract-site-minimap-toggle`, label: 'Contract minimap when opening PiP' }, () => {
        const inputEl = document.createElement('input');
        inputEl.type = 'checkbox';
        inputEl.className = IRF.ui.panel.styles.toggle;
        inputEl.checked = settings.autoContractSiteMinimap;

        inputEl.addEventListener('change', async () => {
          settings.autoContractSiteMinimap = inputEl.checked;
          await saveSettings();
        });

        return inputEl;
      })
    );
  }

  {
    const mapEl = await IRF.dom.map;

    const originalInfoButtonEl = mapEl.querySelector('.info-button');
    const originalInfoButtonComputedStyle = window.getComputedStyle(originalInfoButtonEl);

    GM_addStyle(`
    .map-container {
      & .${cssClass('toggle-pip')} {
        bottom: calc(2 * ${originalInfoButtonComputedStyle.bottom} + ${originalInfoButtonComputedStyle.height});

        & img {
          padding: 0.125rem;
        }

        body.${cssClass('is-in-pip')} & {
          display: none;
        }
      }

      & .${cssClass('placeholder-map')} {
        text-align: center;
        font-size: 0.9rem;
        color: white;
        background-color: rgba(0 0 0 / 75%);
        user-select: none;
        display: flex;
        flex-wrap: wrap;
        flex-direction: column;
        gap: 0.25rem;
        align-items: center;
        place-content: center;

        & .${cssClass('placeholder-map__bring-back-button')} {
          -webkit-appearance: none;
          appearance: none;
          color: black;
          background-color: white;
          border: none;
          border-radius: 4px;
          padding: 0.25rem;
          cursor: pointer;
        }
      }
    }
    `);

    const togglePiPButtonEl = originalInfoButtonEl.cloneNode(/* deep: */ true);
    togglePiPButtonEl.className = `info-button ${cssClass('toggle-pip')}`;

    const togglePiPButtonImageEl = togglePiPButtonEl.querySelector('img');
    togglePiPButtonImageEl.src = 'https://www.svgrepo.com/show/347276/picture-in-picture.svg';

    togglePiPButtonEl.addEventListener('click', openPiP);

    await IRF.vdom.map; // FIXME(netux): this is needed so a bunch of stuff doesn't crash? 🤷
    mapEl.appendChild(togglePiPButtonEl);
  }

  function applyEnabledPiPOnlyMinimapTransforms(minimap, { coords, heading }) {
    if (settings.keepMarkerCentered) { // minimap is in PiP
      minimap.flyTo({
        center: [coords.lng, coords.lat],
        animate: false
      });
    }

    if (settings.keepMarkerFacingDirectionOfTravel) {
      minimap.rotateTo(heading);
    }
  }

  {
    const containerVDOM = await IRF.vdom.container;
    const mapVDOM = await IRF.vdom.map;

    containerVDOM.state.changeStop = new Proxy(containerVDOM.methods.changeStop, {
      apply(ogChangeStop, thisArg, args) {
        if (pipWindow) {
          const newHeading = args[3];
          applyEnabledPiPOnlyMinimapTransforms(mapVDOM.data.map, { coords: containerVDOM.data.currentCoords, heading: newHeading });
        }
        return ogChangeStop.apply(thisArg, args);
      }
    })
  }
})();