AtCoder Quote & Click Copy

AtCoder の問題文内の <code> を設定に応じてクォーテーションで囲み、クリックでコピーできるようにします。折りたたみ可能。

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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);
})();