Greasy Fork is available in English.
AtCoder の問題文内の <code> を設定に応じてクォーテーションで囲み、クリックでコピーできるようにします。折りたたみ可能。
// ==UserScript==
// @name AtCoder Quote & Click Copy
// @namespace http://tampermonkey.net/
// @version 2026-02-04
// @description AtCoder の問題文内の <code> を設定に応じてクォーテーションで囲み、クリックでコピーできるようにします。折りたたみ可能。
// @author Not_Leonian
// @match https://atcoder.jp/contests/*/tasks/*
// @icon https://www.google.com/s2/favicons?domain=atcoder.jp
// @run-at document-end
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// ==/UserScript==
(() => {
"use strict";
const STORAGE_KEY = "tm_atcoder_qc_2026_02_02";
const LEGACY_LOCALSTORAGE_KEY = STORAGE_KEY; // 念のため同名を移行対象にする
// デフォルト
const DEFAULT_SETTINGS = Object.freeze({
copyEnabled: true, // コピー機能:ON(チェック済み)
numStyle: "raw", // 数値:そのまま
charStyle: "single", // 1 文字:シングルクォーテーション
otherStyle: "double", // その他:ダブルクォーテーション
panelCollapsed: false, // 折りたたみ:なし(開いた状態)
});
let settings = { ...DEFAULT_SETTINGS };
// UI 参照
const ui = {
host: null,
shadow: null,
wrap: null,
toggle: null,
panel: null,
checkCopy: null,
selNum: null,
selChar: null,
selOther: null,
toast: null,
};
// observer
let observer = null;
let mutateDebounceTimer = 0;
let toastTimer = 0;
// ---------------- Storage (Tampermonkey 優先) ----------------
function canUseGM() {
return (
typeof GM_getValue === "function" && typeof GM_setValue === "function"
);
}
function readStoredRaw() {
try {
if (canUseGM()) return GM_getValue(STORAGE_KEY, null);
} catch {}
try {
return localStorage.getItem(LEGACY_LOCALSTORAGE_KEY);
} catch {
return null;
}
}
function writeStoredRaw(raw) {
let ok = false;
try {
if (canUseGM()) {
GM_setValue(STORAGE_KEY, raw);
ok = true;
}
} catch {
/* ignore */
}
// GM が使えない環境の保険
if (!ok) {
try {
localStorage.setItem(LEGACY_LOCALSTORAGE_KEY, raw);
} catch {
/* ignore */
}
}
}
function sanitizeSettings(obj) {
const out = {};
if (typeof obj !== "object" || obj === null) return out;
if (typeof obj.copyEnabled === "boolean") out.copyEnabled = obj.copyEnabled;
if (typeof obj.panelCollapsed === "boolean")
out.panelCollapsed = obj.panelCollapsed;
const numOk = ["raw", "double", "single", "mixed"];
const simpleOk = ["raw", "double", "single"];
if (numOk.includes(obj.numStyle)) out.numStyle = obj.numStyle;
if (simpleOk.includes(obj.charStyle)) out.charStyle = obj.charStyle;
if (simpleOk.includes(obj.otherStyle)) out.otherStyle = obj.otherStyle;
return out;
}
function loadSettings() {
// GM に無ければ localStorage から読み、GM へ移行(できれば)する
const raw = readStoredRaw();
if (!raw) return { ...DEFAULT_SETTINGS };
try {
const parsed = JSON.parse(raw);
const merged = {
...DEFAULT_SETTINGS,
...sanitizeSettings(parsed),
};
// 読めた時点で GM へ再保存(移行)
try {
writeStoredRaw(JSON.stringify(merged));
} catch {
/* ignore */
}
return merged;
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings() {
try {
writeStoredRaw(JSON.stringify(settings));
} catch {
/* ignore */
}
}
// ---------------- Categorization ----------------
const NUMERIC_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
function isNumericString(s) {
if (typeof s !== "string" || s.length === 0) return false;
// 空白付きは誤判定を避ける
if (s.trim() !== s) return false;
if (!NUMERIC_RE.test(s)) return false;
const n = Number(s);
return Number.isFinite(n);
}
function isAsciiSingleCharNonDigit(s) {
if (typeof s !== "string" || s.length !== 1) return false;
const code = s.charCodeAt(0);
if (code > 0x7f) return false; // ASCII
return !/^[0-9]$/.test(s); // 0-9 以外
}
function categorize(originalText) {
if (isNumericString(originalText)) return "num";
if (isAsciiSingleCharNonDigit(originalText)) return "char";
return "other";
}
// ---------------- Transform ----------------
function quote(text, q) {
return `${q}${text}${q}`;
}
function transformText(originalText, category) {
if (category === "num") {
switch (settings.numStyle) {
case "raw":
return originalText;
case "double":
return quote(originalText, '"');
case "single":
return quote(originalText, "'");
case "mixed":
// 「0-9 のみシングル、それ以外はダブル」
return /^[0-9]$/.test(originalText)
? quote(originalText, "'")
: quote(originalText, '"');
default:
return originalText;
}
}
if (category === "char") {
switch (settings.charStyle) {
case "raw":
return originalText;
case "double":
return quote(originalText, '"');
case "single":
return quote(originalText, "'");
default:
return originalText;
}
}
// other
switch (settings.otherStyle) {
case "raw":
return originalText;
case "double":
return quote(originalText, '"');
case "single":
return quote(originalText, "'");
default:
return originalText;
}
}
// ---------------- DOM Targets ----------------
function getStatementRoot() {
return document.querySelector("#task-statement");
}
function getTargetCodes(statementRoot) {
// task-statement 内の <code> をすべて対象
return statementRoot.querySelectorAll("code");
}
// ---------------- Styles for page (code hover etc.) ----------------
function ensurePageStyles() {
if (document.getElementById("tm-atcoder-qc-page-style")) return;
const style = document.createElement("style");
style.id = "tm-atcoder-qc-page-style";
style.textContent = `
body.tm-atcoder-qc-copy-enabled #task-statement code.tm-atcoder-qc-target{
cursor: pointer !important;
}
body.tm-atcoder-qc-copy-enabled #task-statement code.tm-atcoder-qc-target:hover{
outline: 1px dashed rgba(0,0,0,0.35) !important;
outline-offset: 2px !important;
}
#task-statement code.tm-atcoder-qc-target.tm-atcoder-qc-copied{
outline: 2px solid rgba(0, 160, 0, 0.55) !important;
outline-offset: 2px !important;
}
`;
document.head.appendChild(style);
}
// ---------------- UI (Shadow DOM + right tab) ----------------
function ensurePanel() {
let host = document.getElementById("tm-atcoder-qc-host");
if (!host) {
host = document.createElement("div");
host.id = "tm-atcoder-qc-host";
document.body.appendChild(host);
host.attachShadow({ mode: "open" });
}
ui.host = host;
ui.shadow = host.shadowRoot;
// 既に構築済みなら参照だけ取り直す
const existingWrap = ui.shadow.getElementById("wrap");
if (existingWrap) {
ui.wrap = existingWrap;
ui.toggle = ui.shadow.getElementById("toggle");
ui.panel = ui.shadow.getElementById("panel");
ui.checkCopy = ui.shadow.getElementById("checkCopy");
ui.selNum = ui.shadow.getElementById("selNum");
ui.selChar = ui.shadow.getElementById("selChar");
ui.selOther = ui.shadow.getElementById("selOther");
ui.toast = ui.shadow.getElementById("toast");
// 二重バインド防止
if (ui.wrap.dataset.bound !== "1") bindUIEvents();
return;
}
const style = document.createElement("style");
style.textContent = `
:host{
all: initial;
position: fixed;
right: 0px;
top: 72px; /* ナビバーと干渉しにくい位置 */
z-index: 2147483647;
display: block;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: #111;
}
/* 右端:縦タブ + パネル */
.wrap{
display: flex;
align-items: flex-start;
pointer-events: auto;
}
.toggle{
all: unset;
box-sizing: border-box;
width: 28px;
padding: 10px 0;
background: rgba(255,255,255,0.98);
border: 1px solid rgba(0,0,0,0.18);
border-right: none;
border-radius: 10px 0 0 10px;
box-shadow: 0 8px 22px rgba(0,0,0,0.18);
cursor: pointer;
user-select: none;
text-align: center;
}
.toggle:focus{
outline: 2px solid rgba(0,0,0,0.35);
outline-offset: 2px;
}
.toggle .vtext{
writing-mode: vertical-rl;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
}
/* パネル本体 */
.panel{
box-sizing: border-box;
margin-right: 6px;
background: rgba(255,255,255,0.98);
border: 1px solid rgba(0,0,0,0.18);
border-radius: 10px;
box-shadow: 0 8px 22px rgba(0,0,0,0.18);
padding: 10px 10px 8px;
width: 240px;
/* 小さい画面でもはみ出しにくいように */
max-width: calc(100vw - 28px - 6px - 12px);
}
.wrap.collapsed .panel{
display: none;
}
.title{
font-weight: 700;
font-size: 12px;
margin: 0 0 8px;
}
.row{ margin-top: 8px; }
.label{
font-size: 11px;
opacity: 0.85;
margin-bottom: 2px;
}
.check{
display: flex;
align-items: center;
gap: 6px;
user-select: none;
font-size: 12px;
}
select{
width: 100%;
font-size: 12px;
padding: 4px 6px;
border: 1px solid rgba(0,0,0,0.25);
border-radius: 6px;
background: #fff;
color: #111;
}
.hint{
margin-top: 8px;
font-size: 11px;
opacity: 0.75;
}
.toast{
margin-top: 8px;
font-size: 11px;
opacity: 0;
transform: translateY(-4px);
transition: opacity 0.15s ease, transform 0.15s ease;
}
.toast.show{
opacity: 0.9;
transform: translateY(0);
}
`;
const wrap = document.createElement("div");
wrap.id = "wrap";
wrap.className = "wrap";
const panel = document.createElement("div");
panel.className = "panel";
panel.id = "panel";
const toggle = document.createElement("button");
toggle.id = "toggle";
toggle.className = "toggle";
toggle.type = "button";
toggle.setAttribute("aria-label", "Toggle Panel");
toggle.setAttribute("aria-expanded", "true");
const vtext = document.createElement("div");
vtext.className = "vtext";
vtext.textContent = "Close";
toggle.appendChild(vtext);
const title = document.createElement("div");
title.className = "title";
title.textContent = "Quote & Click Copy";
panel.appendChild(title);
// checkbox
const checkLabel = document.createElement("label");
checkLabel.className = "check";
const check = document.createElement("input");
check.id = "checkCopy";
check.type = "checkbox";
checkLabel.appendChild(check);
const checkText = document.createElement("span");
checkText.textContent = "コピー機能";
checkLabel.appendChild(checkText);
panel.appendChild(checkLabel);
// selects
const selNum = makeSelectRow(panel, "数値", "selNum", [
["raw", "そのまま"],
["double", "ダブルクォーテーション"],
["single", "シングルクォーテーション"],
["mixed", "シングル / ダブル 使い分け"],
]);
const selChar = makeSelectRow(panel, "1 文字", "selChar", [
["raw", "そのまま"],
["double", "ダブルクォーテーション"],
["single", "シングルクォーテーション"],
]);
const selOther = makeSelectRow(panel, "その他", "selOther", [
["raw", "そのまま"],
["double", "ダブルクォーテーション"],
["single", "シングルクォーテーション"],
]);
const hint = document.createElement("div");
hint.className = "hint";
hint.textContent = "※文字列をクリックでコピー";
panel.appendChild(hint);
const toast = document.createElement("div");
toast.className = "toast";
toast.id = "toast";
toast.textContent = "";
panel.appendChild(toast);
wrap.appendChild(panel);
wrap.appendChild(toggle);
ui.shadow.appendChild(style);
ui.shadow.appendChild(wrap);
// refs
ui.wrap = wrap;
ui.toggle = toggle;
ui.panel = panel;
ui.checkCopy = check;
ui.selNum = selNum;
ui.selChar = selChar;
ui.selOther = selOther;
ui.toast = toast;
bindUIEvents();
}
function bindUIEvents() {
ui.wrap.dataset.bound = "1";
ui.toggle.addEventListener("click", () => {
settings.panelCollapsed = !settings.panelCollapsed;
saveSettings();
applyCollapsedState();
});
// events
ui.checkCopy.addEventListener("change", () => {
settings.copyEnabled = ui.checkCopy.checked;
saveSettings();
updateCopyEnabledClass();
});
ui.selNum.addEventListener("change", () => {
settings.numStyle = ui.selNum.value;
saveSettings();
refreshAll(); // 即反映
showToast("Updated");
});
ui.selChar.addEventListener("change", () => {
settings.charStyle = ui.selChar.value;
saveSettings();
refreshAll(); // 即反映
showToast("Updated");
});
ui.selOther.addEventListener("change", () => {
settings.otherStyle = ui.selOther.value;
saveSettings();
refreshAll(); // 即反映
showToast("Updated");
});
}
function makeSelectRow(panel, labelText, selectId, options) {
const row = document.createElement("div");
row.className = "row";
const label = document.createElement("div");
label.className = "label";
label.textContent = labelText;
row.appendChild(label);
const select = document.createElement("select");
select.id = selectId;
for (const [value, text] of options) {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = text;
select.appendChild(opt);
}
row.appendChild(select);
panel.appendChild(row);
return select;
}
function syncUIFromSettings() {
if (!ui.checkCopy) return;
ui.checkCopy.checked = !!settings.copyEnabled;
ui.selNum.value = settings.numStyle;
ui.selChar.value = settings.charStyle;
ui.selOther.value = settings.otherStyle;
applyCollapsedState();
updateCopyEnabledClass();
}
function applyCollapsedState() {
if (!ui.wrap) return;
const collapsed = !!settings.panelCollapsed;
ui.wrap.classList.toggle("collapsed", collapsed);
ui.toggle.setAttribute("aria-expanded", String(!collapsed));
const vtext = ui.toggle.querySelector(".vtext");
if (vtext) vtext.textContent = collapsed ? "Open" : "Close";
}
function updateCopyEnabledClass() {
document.body.classList.toggle(
"tm-atcoder-qc-copy-enabled",
!!settings.copyEnabled,
);
}
function showToast(message) {
if (!ui.toast) return;
ui.toast.textContent = message;
ui.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => ui.toast.classList.remove("show"), 700);
}
// ---------------- Apply / Observe ----------------
function ensureTargets(statementRoot) {
const codes = getTargetCodes(statementRoot);
codes.forEach((codeEl) => {
if (!(codeEl instanceof HTMLElement)) return;
if (codeEl.classList.contains("tm-atcoder-qc-target")) return;
const original = codeEl.textContent ?? "";
codeEl.dataset.tmQcOriginal = original;
codeEl.dataset.tmQcCategory = categorize(original);
codeEl.classList.add("tm-atcoder-qc-target");
codeEl.addEventListener("click", onCodeClick);
applyTransform(codeEl);
});
}
function applyTransform(codeEl) {
const original = codeEl.dataset.tmQcOriginal ?? codeEl.textContent ?? "";
const category = codeEl.dataset.tmQcCategory ?? categorize(original);
codeEl.dataset.tmQcCategory = category;
const transformed = transformText(original, category);
if (codeEl.textContent !== transformed) codeEl.textContent = transformed;
}
function refreshAll() {
const statementRoot = getStatementRoot();
if (!statementRoot) return;
ensureTargets(statementRoot);
const targets = statementRoot.querySelectorAll("code.tm-atcoder-qc-target");
targets.forEach((el) => {
applyTransform(el);
});
}
function setupObserver(statementRoot) {
if (observer) observer.disconnect();
observer = new MutationObserver(() => {
if (mutateDebounceTimer) return;
mutateDebounceTimer = setTimeout(() => {
mutateDebounceTimer = 0;
refreshAll();
}, 120);
});
observer.observe(statementRoot, { childList: true, subtree: true });
}
// ---------------- Copy ----------------
async function onCodeClick(e) {
if (!settings.copyEnabled) return;
const codeEl = e.currentTarget;
if (!(codeEl instanceof HTMLElement)) return;
const original = codeEl.dataset.tmQcOriginal ?? codeEl.textContent ?? "";
const category = codeEl.dataset.tmQcCategory ?? categorize(original);
const text = transformText(original, category);
const ok = await copyToClipboard(text);
if (ok) {
codeEl.classList.add("tm-atcoder-qc-copied");
setTimeout(() => codeEl.classList.remove("tm-atcoder-qc-copied"), 450);
showToast("Copied!");
} else {
showToast("Copy failed");
}
}
async function copyToClipboard(text) {
// Modern API
try {
if (
navigator.clipboard &&
typeof navigator.clipboard.writeText === "function"
) {
await navigator.clipboard.writeText(text);
try {
const sel = window.getSelection();
if (sel) sel.removeAllRanges();
} catch {}
return true;
}
} catch {
// fallthrough
}
// Fallback
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.top = "-9999px";
ta.style.right = "-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
try {
const sel = window.getSelection();
if (sel) sel.removeAllRanges();
} catch {}
return ok;
} catch {
return false;
}
}
// ---------------- Init ----------------
function init() {
const statementRoot = getStatementRoot();
if (!statementRoot) return;
// 毎回ロードして UI に反映(turbolinks 等でも崩れにくく)
settings = loadSettings();
ensurePageStyles();
ensurePanel();
syncUIFromSettings();
refreshAll();
setupObserver(statementRoot);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// AtCoder の遷移系イベント(環境によって来る)
document.addEventListener("turbolinks:load", init);
})();