AtCoder Quote & Click Copy

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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