コードブロックをPrettierでフォーマットするUserScript
https://gyazo.com/f148ea1a6a716af301e4463efd343283
実装
code:script.js
// =============================================================================
// 1. 設定
// =============================================================================
const PRETTIER_VERSION = "2.8.8";
const BASE = https://cdnjs.cloudflare.com/ajax/libs/prettier/${PRETTIER_VERSION};
const PARSER_BY_EXT = {
js: "babel", mjs: "babel", cjs: "babel", jsx: "babel",
ts: "typescript", tsx: "typescript",
json: "json", json5: "json5",
css: "css", scss: "scss", less: "less",
html: "html", htm: "html", vue: "vue",
md: "markdown", markdown: "markdown",
yaml: "yaml", yml: "yaml",
};
// =============================================================================
// 2. Prettier ロード(遅延)
// =============================================================================
const loadScript = (src) =>
new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.onload = () => resolve();
s.onerror = () => reject(new Error(load failed: ${src}));
document.head.appendChild(s);
});
let prettierPromise = null;
function ensurePrettier() {
if (prettierPromise) return prettierPromise;
prettierPromise = (async () => {
await loadScript(${BASE}/standalone.js);
await Promise.all(
[
"parser-babel", "parser-typescript", "parser-postcss",
"parser-html", "parser-markdown", "parser-yaml",
].map((p) => loadScript(${BASE}/${p}.js))
);
})();
return prettierPromise;
}
// =============================================================================
// 3. ボタンの見た目(コピーボタンに揃える)
// =============================================================================
const style = document.createElement("style");
style.textContent = `
.code-block-start .tool-buttons .button.format-code {
cursor: pointer;
margin-left: 4px;
}
.code-block-start .tool-buttons .button.format-code.busy { opacity: .5; cursor: wait; }
.code-block-start .tool-buttons .button.format-code.ok { color: #2a9d8f; } .code-block-start .tool-buttons .button.format-code.err { color: #e76f51; } `;
document.head.appendChild(style);
// =============================================================================
// 4. ヘッダ DOM → 行 ID / ファイル名
// =============================================================================
function getHeaderInfo(codeBlockStartEl) {
// 親をたどって .line 要素を見つける
const lineEl = codeBlockStartEl.closest(".line");
if (!lineEl) return null;
const lineId = lineEl.id?.replace(/^L/, "");
if (!lineId) return null;
// ヘッダ内のリンク href からファイル名を取り出す
const href = a?.getAttribute("href") || "";
const filename = decodeURIComponent(href.split("/").pop() || "");
return { lineId, filename };
}
// =============================================================================
// 5. ヘッダ行 index → 本文の塊
// =============================================================================
function parseBlockAt(headerIndex) {
const lines = scrapbox.Page.lines;
const m = header?.text.match(/^(\s*)code:(.+)$/);
if (!m) return null;
const headerIndent = m1.length; if (!next) return null;
const im = next.text.match(/^(\s+)/);
if (!im) return null;
const bodyIndent = im1.length; if (bodyIndent <= headerIndent) return null;
const bodyLines = [];
for (let j = headerIndex + 1; j < lines.length; j++) {
if (t.length < bodyIndent) break;
if (!/^\s/.test(t.slice(0, bodyIndent))) break;
}
if (bodyLines.length === 0) return null;
const code = bodyLines.map((l) => l.text.slice(bodyIndent)).join("\n");
return { bodyIndent, bodyLines, code };
}
// =============================================================================
// 6. 整形 + 書き戻し
// =============================================================================
async function formatBlockByHeaderId(headerLineId, filename) {
const ext = (filename.split(".").pop() || "").toLowerCase();
const parser = PARSER_BY_EXText; if (!parser) throw new Error(未対応の拡張子: .${ext});
const idx = scrapbox.Page.lines.findIndex((l) => l.id === headerLineId);
if (idx === -1) throw new Error("ヘッダ行が見つかりません");
const block = parseBlockAt(idx);
if (!block) throw new Error("コードブロック本文が空です");
const formatted = await Promise.resolve(
window.prettier.format(block.code, {
parser,
plugins: window.prettierPlugins,
tabWidth: 2,
semi: true,
singleQuote: false,
})
);
const pad = " ".repeat(block.bodyIndent);
const newBodyLines = formatted
.replace(/\n+$/, "")
.split("\n")
.map((l) => pad + l);
// ★ ここを変更:常に選択範囲ベースで一括置換
await replaceBodyBySelection(block, newBodyLines.join("\n"));
}
async function replaceBodyBySelection(block, newText) {
const textInput = document.getElementById("text-input");
if (!textInput) throw new Error("#text-input が見つかりません");
const first = document.getElementById(L${block.bodyLines[0].id});
const last = document.getElementById(L${block.bodyLines[block.bodyLines.length - 1].id});
if (!first || !last) throw new Error("対象の行DOMが見つかりません");
first.scrollIntoView({ block: "nearest" });
const fire = (el, type, x, y, shift = false) =>
el.dispatchEvent(new MouseEvent(type, {
bubbles: true, cancelable: true, view: window,
clientX: x, clientY: y, button: 0, shiftKey: shift,
}));
const fr = first.getBoundingClientRect();
fire(first, t, fr.left + 1, fr.top + fr.height / 2)
);
await new Promise((r) => setTimeout(r, 30));
const lr = last.getBoundingClientRect();
fire(last, t, lr.right - 1, lr.top + lr.height / 2, true)
);
await new Promise((r) => setTimeout(r, 30));
textInput.focus();
document.execCommand("insertText", false, newText);
}
// =============================================================================
// 7. ボタン挿入
// =============================================================================
function ensureFormatButton(codeBlockStartEl) {
const tools = codeBlockStartEl.querySelector(":scope > .tool-buttons");
if (!tools) return;
if (tools.querySelector(":scope > .button.format-code")) return;
const info = getHeaderInfo(codeBlockStartEl);
if (!info) return;
const ext = (info.filename.split(".").pop() || "").toLowerCase();
if (!PARSER_BY_EXText) return; const btn = document.createElement("span");
btn.className = "button format-code";
btn.title = Format ${info.filename};
btn.innerHTML = '<i class="fas fa-magic"></i>';
// ボタンを押してもキャレットがコードブロックに飛ばないようにする
btn.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); });
btn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
btn.classList.remove("ok", "err");
btn.classList.add("busy");
try {
await ensurePrettier();
await formatBlockByHeaderId(info.lineId, info.filename);
btn.classList.remove("busy");
btn.classList.add("ok");
setTimeout(() => btn.classList.remove("ok"), 1500);
} catch (err) {
btn.classList.remove("busy");
btn.classList.add("err");
btn.title = Format error: ${err.message};
alert("整形エラー: " + err.message);
}
});
tools.appendChild(btn);
}
function injectAllButtons(root = document) {
root.querySelectorAll(".code-block-start").forEach(ensureFormatButton);
}
// =============================================================================
// 8. DOM 変化の監視
// =============================================================================
function isOurOwnMutation(mut) {
if (nodes.length === 0) return false;
return nodes.every(
(n) => n.nodeType === 1 && n.classList?.contains?.("format-code")
);
}
let injectTimer = 0;
function scheduleInject() {
clearTimeout(injectTimer);
injectTimer = setTimeout(() => injectAllButtons(), 80);
}
function start() {
const editor = document.querySelector("#editor") || document.body;
new MutationObserver((muts) => {
if (muts.every(isOurOwnMutation)) return;
scheduleInject();
}).observe(editor, { childList: true, subtree: true });
scheduleInject();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
hr.icon