Refavorited+

More favorite for wplace.live

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Refavorited+
// @namespace    lobo-refavorited-plus
// @version      0.4
// @description  More favorite for wplace.live
// @author       lobo (forked from allanf181)
// @license      MIT
// @match        *://wplace.live/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=wplace.live
// @homepageURL  https://github.com/olob0/wplace-refavorited-plus
// @require      https://cdn.jsdelivr.net/npm/[email protected]/fuzzysort.min.js
// @require      https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(function () {
  `use strict`;

  const __VERSION = 0.4;
  const __NAME = "Refavorited+";

  const pageWindow = unsafeWindow;
  let lastClickedPixelInfo = null; // { tile: [Number, Number], pixel: [Number, Number] }
  let modalHasInjected = false;
  let favButtonListHasInjected = false;
  let observerInjectFavListButtonExecuted = false;

  console.log = (...args) =>
    pageWindow.console.log(
      `%c[${__NAME}] INFO:`,
      "color: #d6d6d6; font-weight: bold;",
      ...args
    );
  console.error = (...args) =>
    pageWindow.console.log(
      `%c[${__NAME}] ERROR:`,
      "color: #ff3636; font-weight: bold;",
      ...args
    );
  console.warn = (...args) =>
    pageWindow.console.log(
      `%c[${__NAME}] WARN:`,
      "color: #fff236; font-weight: bold;",
      ...args
    );

  console.log("hooking fetch");
  const originalFetch = pageWindow.fetch.bind(pageWindow);

  pageWindow.fetch = function (url, options) {
    try {
      const urlString = String(url);
      if (urlString.startsWith("https://backend.wplace.live/s0/pixel/")) {
        const urlObj = new URL(urlString);
        const pathParts = urlObj.pathname.split("/");
        const params = urlObj.searchParams;
        const tile = [parseInt(pathParts[3]), parseInt(pathParts[4])];
        const pixel = [parseInt(params.get("x")), parseInt(params.get("y"))];

        lastClickedPixelInfo = {
          tile,
          pixel,
        };

        console.log(
          `detected last clicked pixel at ${JSON.stringify(
            lastClickedPixelInfo
          )}`
        );

        window.requestAnimationFrame(updateFavPlusButtonState);
      }
    } catch (e) {
      console.error("fetch hook error:", e);
    }

    return originalFetch.apply(this, arguments);
  };

  function waitForElement(selector) {
    return new Promise((resolve) => {
      const elementNow = document.querySelector(selector);
      if (elementNow) return resolve(elementNow);
      const observer = new MutationObserver((mutations) => {
        const element = document.querySelector(selector);
        if (element) {
          observer.disconnect();
          resolve(element);
        }
      });
      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });
    });
  }

  function getLatLngFromPos(tile, pixel) {
    const TILE_SIZE_PX = 1000.0,
      NUM_TILES = 2048.0,
      ZOOM_LEVEL = 11.0;
    const N_POW = Math.pow(2.0, ZOOM_LEVEL);
    const x_normalized = tile[0] + pixel[0] / TILE_SIZE_PX;
    const y_normalized = tile[1] + pixel[1] / TILE_SIZE_PX;
    const lon = ((x_normalized + 0.5) / N_POW) * 360.0 - 180.0;
    const latRad = Math.atan(
      Math.sinh(Math.PI * (1 - (2 * (y_normalized + 0.5)) / N_POW))
    );
    const lat = (latRad * 180.0) / Math.PI;
    return [lat, lon];
  }

  function pixelInfoToPos(tile, pixel) {
    return {
      coords: getLatLngFromPos(tile, pixel),
      pixel: pixel,
      tile: tile,
    };
  }

  function addFavorite(title, tile, pixel) {
    let favorites = getAllFavorites();

    favorites.push({
      title: title,
      posObj: pixelInfoToPos(tile, pixel),
    });

    localStorage.setItem("favorites", JSON.stringify(favorites));

    console.log("adding favorite: " + title);
  }

  function removeFavorite(posObj) {
    console.log("remove favorite");
    let favorites = getAllFavorites().filter(
      (fav) =>
        !(
          fav.posObj.pixel[0] === posObj.pixel[0] &&
          fav.posObj.pixel[1] === posObj.pixel[1] &&
          fav.posObj.tile[0] === posObj.tile[0] &&
          fav.posObj.tile[1] === posObj.tile[1]
        )
    );
    localStorage.setItem("favorites", JSON.stringify(favorites));
  }

  function findFavoriteByPos(tile, pixel) {
    return getAllFavorites().find(
      (fav) =>
        fav.posObj.pixel[0] === pixel[0] &&
        fav.posObj.pixel[1] === pixel[1] &&
        fav.posObj.tile[0] === tile[0] &&
        fav.posObj.tile[1] === tile[1]
    );
  }

  function getAllFavorites() {
    console.log("get all favorites");

    return JSON.parse(localStorage.getItem("favorites") || "[]");
  }

  const markerIcon = `
    <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor">
      <path fill="#000a" d="m183-51 79-338L-1-617l346-29 135-319 135 319 346 29-263 228 79 338-297-180L183-51Z"/>
      <path d="m293-203.08 49.62-212.54-164.93-142.84 217.23-18.85L480-777.69l85.08 200.38 217.23 18.85-164.93 142.84L667-203.08 480-315.92 293-203.08Z"/>
    </svg>`;

  const favPlusIcon = `
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="size-4.5">
        <path d="m354-287 126-76 126 77-33-144 111-96-146-13-58-136-58 135-146 13 111 97-33 143ZM233-120l65-281L80-590l288-25 112-265 112 265 288 25-218 189 65 281-247-149-247 149Zm247-350Z"></path>
    </svg>Fav+`;

  function createTd(...lines) {
    const td = document.createElement("td");
    td.className = "fav-td truncate";

    const flat = lines.flat();
    td.innerHTML = flat.join("<br/>");

    return td;
  }

  function renderFavoritesTable(favorites) {
    const tableBody = document.querySelector("#favorite-table-body");

    if (!tableBody) return;

    tableBody.innerHTML = "";

    favorites.forEach((fav, index) => {
      const row = document.createElement("tr");
      row.classList.add(index % 2 === 0 ? "bg-base-100" : "bg-base-200");

      const coords = fav.posObj.coords;

      const tilePixel = [
        `(${fav.posObj.tile[0]}, ${fav.posObj.tile[1]})`,
        `(${fav.posObj.pixel[0]}, ${fav.posObj.pixel[1]})`,
      ];

      const coordsParsed = [
        `${coords[0].toFixed(5)}`,
        `${coords[1].toFixed(5)}`,
      ];

      const tdTitle = createTd(fav.title);
      const tdTilePixel = createTd(tilePixel);
      const tdCoords = createTd(coordsParsed);

      const tdButtons = createTd("");
      tdButtons.innerHTML = `
    <button class="btn btn-sm btn-primary btn-soft" data-coords='${JSON.stringify(
      coords
    )}'>Visit</button>
    <button class="btn btn-sm btn-error btn-soft" data-posobj='${JSON.stringify(
      fav.posObj
    )}'>Delete</button>
  `;

      row.appendChild(tdTitle);
      row.appendChild(tdTilePixel);
      row.appendChild(tdCoords);
      row.appendChild(tdButtons);

      tableBody.appendChild(row);
    });

    tableBody.querySelectorAll("button.btn-primary").forEach((button) => {
      button.onclick = function () {
        let coords = JSON.parse(this.getAttribute("data-coords"));

        const url = `https://wplace.live/?lat=${coords[0]}&lng=${coords[1]}&zoom=15`;

        pageWindow.location.href = url;
      };
    });
    tableBody.querySelectorAll("button.btn-error").forEach((button) => {
      button.onclick = function () {
        if (!confirm("Are you sure you want to delete this favorite?")) return;

        removeFavorite(JSON.parse(this.getAttribute("data-posobj")));
        filterAndRenderFavorites(
          document.querySelector("#favorite-search").value
        );
      };
    });
  }

  function filterAndRenderFavorites(searchTerm) {
    const allFavorites = getAllFavorites();

    if (!searchTerm) {
      renderFavoritesTable(allFavorites);
      return;
    }

    const results = fuzzysort.go(searchTerm, allFavorites, {
      key: "title",
    });

    renderFavoritesTable(results.map((result) => result.obj));
  }

  function updateFavPlusButtonState() {
    const button = document.querySelector("#favplusbutton");
    if (!button) return;

    if (!lastClickedPixelInfo) {
      button.classList.remove("text-yellow-400");
      return;
    }

    if (
      findFavoriteByPos(lastClickedPixelInfo.tile, lastClickedPixelInfo.pixel)
    ) {
      button.classList.add("text-yellow-400");
    } else {
      button.classList.remove("text-yellow-400");
    }
  }

  function onFavPlusButtonClick() {
    if (!lastClickedPixelInfo) {
      alert("No position data available. Click on the map first.");
      return;
    }

    const { tile, pixel } = lastClickedPixelInfo;
    const existingFav = findFavoriteByPos(tile, pixel);

    if (existingFav) {
      removeFavorite(existingFav.posObj);
      this.classList.remove("text-yellow-400");
    } else {
      let title = prompt("Enter a title for this favorite:");
      if (!title) return;

      addFavorite(title, tile, pixel);
      this.classList.add("text-yellow-400");
    }
  }

  async function registerModalEvents() {
    console.log("registering modal events...");

    console.log("- event search input");
    (await waitForElement("#favorite-search")).addEventListener("input", (e) =>
      filterAndRenderFavorites(e.target.value)
    );

    console.log("- event close modal buton");
    (
      await waitForElement("#favorite-modal label[for='favorite-modal']")
    ).addEventListener("click", () =>
      document.querySelector("#favorite-modal").removeAttribute("open")
    );

    console.log("- event import button");
    (await waitForElement("#import-favorites")).addEventListener(
      "click",
      function () {
        let base64 = prompt("Paste your favorites base64 string here:");
        if (!base64) return;
        try {
          let favorites = JSON.parse(atob(base64));
          if (!Array.isArray(favorites)) {
            throw new Error("Invalid format");
          }
          for (const fav of favorites) {
            if (
              typeof fav.title !== "string" ||
              typeof fav.posObj !== "object" ||
              !Array.isArray(fav.posObj.coords) ||
              !Array.isArray(fav.posObj.pixel) ||
              !Array.isArray(fav.posObj.tile)
            ) {
              throw new Error("Invalid format");
            }
            if (
              fav.posObj.coords.length !== 2 ||
              fav.posObj.pixel.length !== 2 ||
              fav.posObj.tile.length !== 2
            ) {
              throw new Error("Invalid format");
            }
            if (
              typeof fav.posObj.coords[0] !== "number" ||
              typeof fav.posObj.coords[1] !== "number" ||
              typeof fav.posObj.pixel[0] !== "number" ||
              typeof fav.posObj.pixel[1] !== "number" ||
              typeof fav.posObj.tile[0] !== "number" ||
              typeof fav.posObj.tile[1] !== "number"
            ) {
              throw new Error("Invalid format");
            }
          }
          let confirmImport = confirm(
            `This will overwrite your current favorites with ${favorites.length} favorites. Are you sure?`
          );
          if (!confirmImport) return;
          localStorage.setItem("favorites", JSON.stringify(favorites));
          filterAndRenderFavorites("");
          alert("Import successful.");
        } catch (e) {
          alert("Failed to import favorites: " + e.message);
        }
      }
    );

    console.log("- event export button");
    (await waitForElement("#export-favorites")).addEventListener(
      "click",
      function () {
        let base64 = btoa(localStorage.getItem("favorites") || "[]");
        navigator.clipboard.writeText(base64).then(
          () => alert("Favorites exported to clipboard."),
          () => alert("Failed to copy to clipboard.")
        );
      }
    );
  }

  // run when DOM loads
  (async function () {
    let mainDiv = await waitForElement("body > div");

    if (!mainDiv) {
      console.warn("'boy > div' not found. using 'document.body' fallback");
      mainDiv = document.body; // Fallback
    }

    const modalHTML = `
<div id="favorite-modal" class="modal">
  <div class="modal-box max-w-4xl max-h-11/12 p-4">
    <style>
      .fav-table-header, .fav-table-body { width: 100%; table-layout: fixed; border-collapse: collapse; }
      .fav-table-body-wrapper { max-height: 40vh; overflow-y: auto; }
      .fav-th, .fav-td { padding: 0.5rem 0.75rem; text-align: left; vertical-align: middle; }
      .fav-td.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      .fav-table-body { border-top: 1px solid rgba(0,0,0,0.06); }
    </style>

    <div class="flex items-center gap-2 mb-2">
      <svg class="size-6" xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="currentColor">
        <path d="m183-51 79-338L-1-617l346-29 135-319 135 319 346 29-263 228 79 338-297-180L183-51Z"></path>
        <path d="m293-203.08 49.62-212.54-164.93-142.84 217.23-18.85L480-777.69l85.08 200.38 217.23 18.85-164.93 142.84L667-203.08 480-315.92 293-203.08Z"></path>
      </svg>
      <h3 class="font-bold text-lg">${__NAME} <span class="text-sm">v${__VERSION}</span></h3>
      <div class="ml-auto space-x-2">
      <button type="button" id="import-favorites" class="btn btn-sm btn-primary btn-soft">Import</button>
      <button type="button" id="export-favorites" class="btn btn-sm btn-primary btn-soft">Export</button>
        <label for="favorite-modal" class="btn btn-sm btn-circle">✕</label>
      </div>
    </div>

    <p class="text-sm mb-3">By Lobo - <a class="text-primary underline" href="https://github.com/olob0/wplace-refavorited-plus/issues/new/choose" target="_blank">report a bug</a> - <a class="text-primary underline" href="https://github.com/olob0/wplace-refavorited-plus" target="_blank">GitHub</a></p>

    <div class="my-3">
      <input type="text" id="favorite-search" placeholder="Type to search..." class="input input-bordered w-full outline-none" />
    </div>

    <table class="fav-table-header table-fixed w-full" aria-hidden="false">
      <colgroup>
        <col style="width:30%">
        <col style="width:23.333%">
        <col style="width:23.333%">
        <col style="width:23.333%">
      </colgroup>
      <thead class="bg-base-300 font-bold">
        <tr>
          <th class="fav-th fav-th text-left">Title</th>
          <th class="fav-th fav-th text-left">Tile/Pixel</th>
          <th class="fav-th fav-th text-left">Coords</th>
          <th class="fav-th fav-th text-left">Actions</th>
        </tr>
      </thead>
    </table>

    <div class="fav-table-body-wrapper mt-0">
      <table id="favorite-table" class="fav-table-body table-fixed w-full" aria-hidden="false">
        <colgroup>
          <col style="width:30%">
          <col style="width:23.333%">
          <col style="width:23.333%">
          <col style="width:23.333%">
        </colgroup>
        <tbody id="favorite-table-body">
          <!-- linhas inseridas via JS -->
        </tbody>
      </table>
    </div>
  </div>
</div>`;

    const observerMainDiv = new MutationObserver(async () => {
      const selector = document.querySelector("div#favorite-modal");

      if (!selector) {
        mainDiv.insertAdjacentHTML("beforeend", modalHTML);

        if (!modalHasInjected) {
          console.log("modal injected");
        } else {
          console.warn("modal re-injected");
        }

        await registerModalEvents();

        modalHasInjected = true;
      }
    });

    if (!mainDiv) {
      console.error("mainDiv not found");
      return;
    }

    console.log("observing changes to inject modal...");
    observerMainDiv.observe(mainDiv, {
      childList: true,
      subtree: false,
    });

    // fav button

    const pixelMenuSelector = await waitForElement('div[class*="svelte-"]');

    const observerPixelMenu = new MutationObserver(() => {
      const selector = pixelMenuSelector.querySelector(
        'div[class*="absolute"][class*="bottom-"] > div[class*="bg-base-"] div div[class*="hide-scrollbar"]:has(button[class*="btn-primary"])'
      );

      if (!selector) {
        return;
      }

      if (!document.getElementById("favplusbutton")) {
        const btn = document.createElement("button");
        btn.id = "favplusbutton";
        btn.className = "btn btn-primary btn-soft";
        btn.innerHTML = favPlusIcon;
        btn.onclick = onFavPlusButtonClick;

        selector.appendChild(btn);

        console.log("fav button injected and event registerd");

        updateFavPlusButtonState();
      }
    });

    if (!pixelMenuSelector) {
      console.error("pixelMenuSelector not found");
      return;
    }

    console.log("observing changes to inject fav button...");
    observerPixelMenu.observe(pixelMenuSelector, {
      childList: true,
      subtree: false,
    });

    // fav list button

    async function injectFavListButton() {
      observerInjectFavListButtonExecuted = false;

      const rightButtonsContainer = await waitForElement(
        'div[class*="svelte-"] > div[class*="top-"][class*="right-"]'
      );

      const observerButtonsDiv = new MutationObserver(async () => {
        observerInjectFavListButtonExecuted = true;

        const selector = await waitForElement(
          'div[class*="svelte-"] > div[class*="top-"][class*="right-"] div div:has(button[title])'
        );

        if (selector) {
          if (selector.querySelectorAll("button[title]").length < 3) {
            console.log(
              "< 3 buttons founded inside right buttons container. ignoring"
            );
            return;
          }

          if (!selector.querySelector("button#favorite-list")) {
            let element = document.createElement("button");
            selector.appendChild(element);

            element.outerHTML = `<button id="favorite-list" class="btn btn-square relative shadow-md" title="${__NAME} list" >${markerIcon}</button>`;

            if (!favButtonListHasInjected) {
              console.log("fav list button injected");
            } else {
              console.warn("fav list button re-injected");
            }

            document
              .querySelector("button#favorite-list")
              .addEventListener("click", () => {
                document
                  .querySelector("#favorite-modal")
                  .setAttribute("open", "true");
                filterAndRenderFavorites("");
              });

            console.log("fav list button event registred");
          }
        }
      });

      if (!rightButtonsContainer) {
        return;
      }

      observerButtonsDiv.observe(rightButtonsContainer, {
        childList: true,
        subtree: true,
      });
    }

    await injectFavListButton();

    // for some reason, sometimes observer don't trigger. For this cases, this is an very idiot fallback

    setTimeout(() => {
      if (!observerInjectFavListButtonExecuted) {
        console.warn(
          "injectFavListButtonCallback executed from timeout! Observer don't trigged"
        );
        injectFavListButton();
      }
    }, 5000);
  })();
})();