Greasy Fork is available in English.
AtCoder Problemsの問題にブックマーク(☆)を付けて、一覧で色付け表示。AtCoderの問題ページとも同期。
// ==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();
}
})();