AtCoder Problems Bookmark Highlighter

AtCoder Problemsの問題にブックマーク(☆)を付けて、一覧で色付け表示。AtCoderの問題ページとも同期。

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         AtCoder Problems Bookmark Highlighter
// @namespace    https://kenkoooo.com/atcoder/
// @version      1.1.0
// @description  AtCoder Problemsの問題にブックマーク(☆)を付けて、一覧で色付け表示。AtCoderの問題ページとも同期。
// @author       you
// @match        https://kenkoooo.com/atcoder/*
// @match        https://atcoder.jp/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @run-at       document-end
// @license MIT
// ==/UserScript==

(function () {
  "use strict";

  const STORAGE_KEY = "acpBookmarks_v1";

  function loadBookmarks() {
    const raw = GM_getValue(STORAGE_KEY, "{}");
    try {
      const obj = JSON.parse(raw);
      return obj && typeof obj === "object" ? obj : {};
    } catch (e) {
      console.warn("[AtCoderBookmark] failed to parse storage", e);
      return {};
    }
  }

  function saveBookmarks(bookmarks) {
    GM_setValue(STORAGE_KEY, JSON.stringify(bookmarks));
  }

  // ----------------------------------------------------------------
  // AtCoder Problems 側の処理(kenkoooo.com)
  // ----------------------------------------------------------------

  function initAtCoderProblems() {
    GM_addStyle(`
      .acp-bookmark-cell {
        position: relative !important;
        padding-right: 22px !important;
      }
      .acp-bookmark-star {
        position: absolute !important;
        top: 50%;
        right: 4px;
        transform: translateY(-50%);
        cursor: pointer;
        font-size: 13px;
        line-height: 1;
        z-index: 10;
        opacity: 0.6;
        transition: opacity 0.15s ease, transform 0.15s ease;
        user-select: none;
      }
      .acp-bookmark-star:hover {
        opacity: 1.0;
        transform: translateY(-50%) scale(1.2);
      }
      .acp-bookmark-star-on {
        color: #ffcc00;
        -webkit-text-stroke: 1px #000000;
        opacity: 0.7;
      }
      .acp-bookmark-star-off {
        color: #000000;
        opacity: 0.7;
      }
    `);

    const BOOKMARK_BG = "rgb(255, 252, 230)"; // #fffce6
    const BOOKMARK_BG_SET = "#fffce6";
    const BOOKMARK_SHADOW = "inset 0 0 0 4px #f0b400";

    // ブックマーク済みセルの Set(再塗り直しループで使用)
    const bookmarkedCells = new Set();

    // rAFループ:ブックマーク済みセルの色が消えていたら再適用
    let rafId = null;
    function startRepaintLoop() {
      if (rafId !== null) return;
      function loop() {
        for (const cell of bookmarkedCells) {
          const current = cell.style.boxShadow;
          if (current !== BOOKMARK_SHADOW) {
            // cell.style.setProperty("background-color", BOOKMARK_BG_SET, "important");
            cell.style.boxShadow = BOOKMARK_SHADOW;
          }
        }
        rafId = requestAnimationFrame(loop);
      }
      rafId = requestAnimationFrame(loop);
    }

    function stopRepaintLoop() {
      if (rafId !== null) {
        cancelAnimationFrame(rafId);
        rafId = null;
      }
    }

    function setBookmarked(cell, isOn) {
      if (isOn) {
        bookmarkedCells.add(cell);
        // cell.style.setProperty("background-color", BOOKMARK_BG_SET, "important");
        cell.style.boxShadow = BOOKMARK_SHADOW;
        if (bookmarkedCells.size === 1) startRepaintLoop();
      } else {
        bookmarkedCells.delete(cell);
        // cell.style.backgroundColor = "";
        cell.style.boxShadow = "";
        if (bookmarkedCells.size === 0) stopRepaintLoop();
      }
    }

    function getProblemIdFromCell(cell) {
      const probId = cell.getAttribute("data-problem-id");
      if (probId) return probId;
      const link = cell.querySelector("a");
      if (link) {
        const href = link.href || link.getAttribute("href") || "";
        let m = href.match(/\/tasks\/([^/?#\s]+)/);
        if (m) return m[1];
        m = href.match(/[?&]problem=([^&]+)/);
        if (m) return m[1];
        m = href.match(/#.*\/([a-z0-9]+_[a-z0-9]+)/i);
        if (m) return m[1];
      }
      return null;
    }

    function enhanceCell(cell, bookmarks) {
      if (cell.dataset.acpBookmarkProcessed === "1") return;
      const problemId = getProblemIdFromCell(cell);
      if (!problemId) return;

      cell.dataset.acpBookmarkProcessed = "1";
      cell.classList.add("acp-bookmark-cell");

      const star = document.createElement("span");
      star.classList.add("acp-bookmark-star");
      star.dataset.problemId = problemId;

      const isOn = !!bookmarks[problemId];
      star.textContent = isOn ? "★" : "☆";
      star.classList.add(isOn ? "acp-bookmark-star-on" : "acp-bookmark-star-off");
      setBookmarked(cell, isOn);

      star.addEventListener("click", (e) => {
        e.stopPropagation();
        e.preventDefault();
        const bm = loadBookmarks();
        const next = !bm[problemId];
        if (next) bm[problemId] = true;
        else delete bm[problemId];
        saveBookmarks(bm);

        star.textContent = next ? "★" : "☆";
        star.classList.toggle("acp-bookmark-star-on", next);
        star.classList.toggle("acp-bookmark-star-off", !next);
        setBookmarked(cell, next);
      });

      cell.appendChild(star);
    }

    function enhanceTable(root) {
      const bookmarks = loadBookmarks();
      root.querySelectorAll("td, th").forEach((cell) => enhanceCell(cell, bookmarks));
    }

    let scanTimer = null;
    function scheduleScan() {
      if (scanTimer) return;
      scanTimer = setTimeout(() => {
        scanTimer = null;
        document.querySelectorAll("table").forEach((t) => enhanceTable(t));
      }, 300);
    }

    function observeTables() {
      const observer = new MutationObserver((mutations) => {
        let needsScan = false;
        for (const m of mutations) {
          for (const node of m.addedNodes) {
            if (node instanceof HTMLElement) {
              if (node.matches("table, tr, td, th") || node.querySelector("table, tr, td, th")) {
                needsScan = true;
                break;
              }
            }
          }
          if (needsScan) break;
        }
        if (needsScan) scheduleScan();
      });
      observer.observe(document.body, { childList: true, subtree: true });
      scheduleScan();
    }

    window.addEventListener("hashchange", () => setTimeout(scheduleScan, 500));

    if (document.body) {
      observeTables();
    } else {
      document.addEventListener("DOMContentLoaded", observeTables);
    }
  }

  // ----------------------------------------------------------------
  // AtCoder 側の処理(atcoder.jp/tasks/xxx)
  // ----------------------------------------------------------------

  function initAtCoder() {
    if (!location.pathname.includes("/tasks/")) return;
    const m = location.pathname.match(/\/tasks\/([^/?#\s]+)/);
    if (!m) return;
    const problemId = m[1];

    GM_addStyle(`
      #acp-atcoder-star-btn {
        display: inline-flex;
        align-items: center;
        gap: 5px;
        position: fixed;
        top: 60px;
        right: 20px;
        z-index: 9999;
        background: #fff;
        border: 1.5px solid #ccc;
        border-radius: 6px;
        padding: 4px 10px;
        cursor: pointer;
        font-size: 16px;
        box-shadow: 0 2px 6px rgba(0,0,0,0.15);
        user-select: none;
        transition: box-shadow 0.15s ease, border-color 0.15s ease;
      }
      #acp-atcoder-star-btn:hover {
        box-shadow: 0 3px 10px rgba(0,0,0,0.22);
        border-color: #f0b400;
      }
      #acp-atcoder-star-btn .acp-star-icon {
        font-size: 18px;
        line-height: 1;
        transition: transform 0.15s ease;
      }
      #acp-atcoder-star-btn:hover .acp-star-icon {
        transform: scale(1.2);
      }
      #acp-atcoder-star-btn.acp-on {
        background: #fff7c0;
        border-color: #f0b400;
        box-shadow: 0 2px 8px rgba(240,180,0,0.3);
      }
      #acp-atcoder-star-btn .acp-star-icon.acp-on {
        color: #ffcc00;
        text-shadow: 0 0 4px rgba(0,0,0,0.3);
      }
      #acp-atcoder-star-btn .acp-star-icon.acp-off {
        color: #000000;
      }
      #acp-atcoder-star-btn .acp-star-label {
        font-size: 12px;
        color: #555;
        white-space: nowrap;
      }
    `);

    function createStarButton() {
      const btn = document.createElement("div");
      btn.id = "acp-atcoder-star-btn";

      const icon = document.createElement("span");
      icon.className = "acp-star-icon";

      const label = document.createElement("span");
      label.className = "acp-star-label";
      label.textContent = "bookmark";

      btn.appendChild(icon);
      btn.appendChild(label);
      document.body.appendChild(btn);

      function updateState() {
        const bm = loadBookmarks();
        const isOn = !!bm[problemId];
        icon.textContent = isOn ? "★" : "☆";
        icon.className = "acp-star-icon " + (isOn ? "acp-on" : "acp-off");
        if (isOn) btn.classList.add("acp-on");
        else btn.classList.remove("acp-on");
      }

      updateState();

      btn.addEventListener("click", () => {
        const bm = loadBookmarks();
        const next = !bm[problemId];
        if (next) bm[problemId] = true;
        else delete bm[problemId];
        saveBookmarks(bm);
        updateState();
      });
    }

    if (document.body) {
      createStarButton();
    } else {
      document.addEventListener("DOMContentLoaded", createStarButton);
    }
  }

  // ----------------------------------------------------------------
  // ドメインで振り分け
  // ----------------------------------------------------------------

  if (location.hostname === "kenkoooo.com") {
    initAtCoderProblems();
  } else if (location.hostname === "atcoder.jp") {
    initAtCoder();
  }

})();