// ==UserScript==
// @name Google Sites - Code Formatter Modal
// @namespace google-sites-explainpark101-code-formatter
// @version 0.4.2
// @description Allows use of code formatter in Google Sites
// @match https://sites.google.com/d/*/edit
// @grant GM_addStyle
// @grant GM_setClipboard
// @run-at document-idle
// @require https://unpkg.com/[email protected]/standalone.js
// @require https://unpkg.com/[email protected]/plugins/babel.js
// @require https://unpkg.com/[email protected]/plugins/typescript.js
// @require https://unpkg.com/[email protected]/plugins/estree.js
// @require https://unpkg.com/[email protected]/plugins/html.js
// @require https://unpkg.com/[email protected]/plugins/postcss.js
// @require https://unpkg.com/[email protected]/plugins/markdown.js
// @require https://unpkg.com/[email protected]/dist/sql-formatter.min.js
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// -------------------- Styles --------------------
GM_addStyle(`
.exlnprk-open-btn {
position: fixed;
/* 수정: right -> left 로 변경 */
left: max(16px, env(safe-area-inset-left) + 12px);
bottom: max(16px, env(safe-area-inset-bottom) + 12px);
z-index: 2147483647;
background: #1f6feb; color: #fff; border: none; border-radius: 20px;
padding: 10px 14px; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,.2);
transition: transform .12s ease, box-shadow .12s ease;
}
.exlnprk-open-btn:focus { outline: 2px solid #99c2ff; outline-offset: 2px; }
@media (max-width: 640px) {
.exlnprk-open-btn {
bottom: calc(max(16px, env(safe-area-inset-bottom) + 12px) + 56px);
}
}
/* 드래그 중 시각 효과 */
.exlnprk-open-btn.exlnprk-dragging {
transform: scale(0.96);
box-shadow: 0 10px 24px rgba(0,0,0,.35);
cursor: grabbing;
}
.exlnprk-open-btn.exlnprk-dragging::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 24px;
border: 2px dashed #1f6feb;
opacity: .6;
animation: exlnprk-pulse 1s ease-in-out infinite;
pointer-events: none;
}
@keyframes exlnprk-pulse {
0% { opacity: .6; }
50% { opacity: .2; }
100% { opacity: .6; }
}
dialog.exlnprk-dialog {
z-index: 2147483647; border: none; border-radius: 12px; padding: 0;
width: min(920px, 92vw);
color: #1f2328; background: #ffffff; box-shadow: 0 20px 48px rgba(0,0,0,.25);
}
dialog.exlnprk-dialog::backdrop { background: rgba(0,0,0,.35); }
.exlnprk-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid #e6e6e6; background: #f7f9fc;
border-top-left-radius: 12px; border-top-right-radius: 12px;
}
.exlnprk-title { margin: 0; font-size: 16px; font-weight: 700; }
.exlnprk-close {
background: transparent; border: none; font-size: 18px; cursor: pointer;
}
.exlnprk-body { padding: 12px 16px 8px 16px; }
.exlnprk-controls {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px;
}
.exlnprk-controls label { font-size: 13px; color: #3b3f45; }
.exlnprk-controls select, .exlnprk-controls input[type="number"] {
padding: 4px 8px; font-size: 13px;
}
.exlnprk-checkbox { display: inline-flex; align-items: center; gap: 6px; }
.exlnprk-ta {
width: 100%; height: 380px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 13px; line-height: 1.5; padding: 10px 12px; border: 1px solid #e1e4e8; border-radius: 8px;
}
.exlnprk-footer {
display: flex; gap: 8px; justify-content: space-between; align-items: center; padding: 10px 16px 16px 16px;
}
.exlnprk-btn {
border: 1px solid #d0d7de; background: #f6f8fa; color: #24292f;
padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 13px;
}
.exlnprk-btn.primary { background: #1f6feb; color: #fff; border-color: #1f6feb; }
.exlnprk-btn:focus { outline: 2px solid #99c2ff; outline-offset: 2px; }
.exlnprk-status { font-size: 12px; color: #59636e; padding: 0 16px 12px 16px; min-height: 18px; }
.exlnprk-left { display: flex; gap: 8px; }
.exlnprk-right { display: flex; gap: 8px; }
.exlnprk-body {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.exlnprk-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.exlnprk-ta {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
resize: vertical;
overflow: auto;
}
`);
// -------------------- Constants & Utils --------------------
const LS_KEYS = {
customLangs: 'exlnprk-customLangs',
lastLang: 'exlnprk-lastLang',
btnPos: 'exlnprk-open-btn-pos',
};
const baseLangs = ['python', 'javascript', 'sql', 'css', 'html', 'vue', 'cpp', 'rust', 'go', 'r'];
const prettierParsers = {
javascript: 'babel',
typescript: 'typescript',
html: 'html',
css: 'css',
json: 'json',
markdown: 'markdown',
vue: 'vue',
};
const loadCustomLangs = () => {
try { return JSON.parse(localStorage.getItem(LS_KEYS.customLangs) || '[]'); }
catch { return []; }
};
const saveCustomLangs = (arr) => {
try { localStorage.setItem(LS_KEYS.customLangs, JSON.stringify(arr)); } catch {}
};
const getIndent = () => Math.min(Math.max(parseInt(indentInput.value || '2', 10), 1), 8);
const tabsToSpaces = (text, size) => text.replace(/\t/g, ' '.repeat(size));
const normalizeEOL = (text) => text.replace(/\r\n?/g, '\n');
function setStatus(msg, isError = false) {
status.textContent = msg || '';
status.style.color = isError ? '#b42318' : '#59636e';
}
function ensurePrettier() {
const has = !!(globalThis.prettier && globalThis.prettierPlugins);
if (!has) setStatus('Prettier 로딩 중이거나 사용 불가 상태입니다.', true);
return has;
}
function ensureSqlFormatter() {
return !!globalThis.sqlFormatter;
}
function displayName(v) {
const map = {
python: 'Python', javascript: 'JavaScript', sql: 'SQL', css: 'CSS',
html: 'HTML', vue: 'Vue', cpp: 'C++', rust: 'Rust', go: 'Go', r: 'R'
};
return map[v] || v;
}
function option(value, text) {
const o = document.createElement('option'); o.value = value; o.textContent = text; return o;
}
function isCustomValue(v) { return v.startsWith('custom:'); }
function customValue(name) { return 'custom:' + name; }
// -------------------- Elements (Trusted Types-safe) --------------------
const openBtn = document.createElement('button');
openBtn.className = 'exlnprk-open-btn';
openBtn.type = 'button';
openBtn.setAttribute('aria-label', 'Code Formatter 열기');
openBtn.textContent = 'Code Formatter';
const dlg = document.createElement('dialog');
dlg.className = 'exlnprk-dialog';
dlg.setAttribute('role', 'dialog');
dlg.setAttribute('aria-modal', 'true');
const header = document.createElement('div');
header.className = 'exlnprk-header';
const title = document.createElement('h2');
title.id = 'exlnprk-title';
title.className = 'exlnprk-title';
title.textContent = '코드 포맷터';
const closeX = document.createElement('button');
closeX.className = 'exlnprk-close';
closeX.type = 'button';
closeX.setAttribute('aria-label', '닫기');
closeX.textContent = '✕';
header.append(title, closeX);
const body = document.createElement('div');
body.className = 'exlnprk-body';
body.setAttribute('aria-labelledby', 'exlnprk-title');
const controls = document.createElement('div');
controls.className = 'exlnprk-controls';
const langLabel = document.createElement('label'); langLabel.textContent = '언어 ';
const langSel = document.createElement('select');
langSel.id = 'exlnprk-lang';
langSel.setAttribute('aria-label', '언어 선택');
langLabel.appendChild(langSel);
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'exlnprk-btn';
addBtn.textContent = '언어 추가';
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'exlnprk-btn';
delBtn.textContent = '언어 삭제';
const indentLabel = document.createElement('label'); indentLabel.textContent = '들여쓰기 ';
const indentInput = document.createElement('input');
indentInput.id = 'exlnprk-indent';
indentInput.type = 'number'; indentInput.min = '1'; indentInput.max = '8'; indentInput.value = '2';
indentInput.setAttribute('aria-label', '들여쓰기 공백 수');
indentLabel.appendChild(indentInput);
const cbWrap = document.createElement('label'); cbWrap.className = 'exlnprk-checkbox';
const autoTabs = document.createElement('input');
autoTabs.id = 'exlnprk-autoTabs'; autoTabs.type = 'checkbox'; autoTabs.checked = true;
cbWrap.append(autoTabs, document.createTextNode('붙여넣을 때 탭을 스페이스로 자동 변환'));
controls.append(langLabel, addBtn, delBtn, indentLabel, cbWrap);
const ta = document.createElement('textarea');
ta.id = 'exlnprk-ta';
ta.className = 'exlnprk-ta';
ta.setAttribute('spellcheck', 'false');
ta.placeholder = '여기에 코드를 붙여넣으세요';
body.append(controls, ta);
const status = document.createElement('div');
status.id = 'exlnprk-status';
status.className = 'exlnprk-status';
status.setAttribute('aria-live', 'polite');
const footer = document.createElement('div');
footer.className = 'exlnprk-footer';
const leftGrp = document.createElement('div'); leftGrp.className = 'exlnprk-left';
const fmtBtn = document.createElement('button');
fmtBtn.id = 'exlnprk-format';
fmtBtn.className = 'exlnprk-btn';
fmtBtn.type = 'button';
fmtBtn.title = 'Ctrl+Enter';
fmtBtn.textContent = '포맷팅';
const copyBtn = document.createElement('button');
copyBtn.id = 'exlnprk-copy';
copyBtn.className = 'exlnprk-btn primary';
copyBtn.type = 'button';
copyBtn.title = 'Ctrl+Shift+C';
copyBtn.textContent = '복사';
leftGrp.append(fmtBtn, copyBtn);
const rightGrp = document.createElement('div'); rightGrp.className = 'exlnprk-right';
const closeBtn2 = document.createElement('button');
closeBtn2.id = 'exlnprk-close';
closeBtn2.className = 'exlnprk-btn';
closeBtn2.type = 'button';
closeBtn2.textContent = '닫기(Esc)';
rightGrp.append(closeBtn2);
footer.append(leftGrp, rightGrp);
dlg.append(header, body, status, footer);
document.documentElement.append(openBtn, dlg);
// -------------------- Language select --------------------
function populateLangs() {
const current = langSel.value;
langSel.replaceChildren();
baseLangs.forEach(l => langSel.appendChild(option(l, displayName(l))));
loadCustomLangs().forEach(name => langSel.appendChild(option(customValue(name), name)));
const last = localStorage.getItem(LS_KEYS.lastLang);
if (last && [...langSel.options].some(o => o.value === last)) langSel.value = last;
else if (current && [...langSel.options].some(o => o.value === current)) langSel.value = current;
}
populateLangs();
// -------------------- Formatters --------------------
async function formatWithPrettier(code, lang) {
const parser = prettierParsers[lang];
if (!parser) return null;
const prettier = globalThis.prettier;
const plugins = globalThis.prettierPlugins;
return await prettier.format(code, {
parser,
plugins,
tabWidth: getIndent(),
useTabs: false,
endOfLine: 'lf',
semi: true,
singleQuote: true,
printWidth: 100,
trailingComma: 'es5',
});
}
function formatWithSqlFormatter(code) {
if (!ensureSqlFormatter()) return null;
try {
return globalThis.sqlFormatter.format(code, { language: 'sql', tabWidth: getIndent() });
} catch (e) {
console.error('[exlnprk] sql format error:', e);
setStatus(`SQL 포맷 실패: ${e?.message || e}`, true);
return null;
}
}
function basicCleanup(text) {
return tabsToSpaces(normalizeEOL(text), getIndent());
}
// -------------------- Core actions --------------------
async function formatCode() {
let code = ta.value;
code = tabsToSpaces(code, getIndent());
const lang = langSel.value;
try {
if (prettierParsers[lang]) {
if (!ensurePrettier()) {
const safe = normalizeEOL(code);
ta.value = safe;
return { ok: false, code: safe, message: 'Prettier 미사용: 기본 정리만 적용' };
}
try {
const formatted = await formatWithPrettier(code, lang);
ta.value = formatted;
return { ok: true, code: formatted };
} catch (err) {
console.warn('[exlnprk] Prettier parse error; fallback to basic cleanup:', err);
setStatus(`포맷 실패(구문 오류 가능). 기본 정리로 대체했습니다.`, true);
const safe = basicCleanup(code);
ta.value = safe;
return { ok: false, code: safe, message: err?.message || String(err) };
}
}
if (lang === 'sql') {
const formatted = formatWithSqlFormatter(code) || normalizeEOL(code);
ta.value = formatted;
return { ok: true, code: formatted };
}
const cleaned = basicCleanup(code);
ta.value = cleaned;
return { ok: true, code: cleaned };
} catch (e) {
console.error('[exlnprk] format error:', e);
setStatus(`포맷팅 실패: ${e?.message || e}`, true);
const safe = normalizeEOL(code);
ta.value = safe;
return { ok: false, code: safe, message: e?.message || String(e) };
}
}
async function copyFormatted() {
const { code } = await formatCode();
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(code);
} else if (typeof GM_setClipboard === 'function') {
GM_setClipboard(code, { type: 'text', mimetype: 'text/plain' });
} else {
const tmp = document.createElement('textarea');
tmp.value = code;
document.body.appendChild(tmp);
tmp.select();
document.execCommand('copy');
tmp.remove();
}
setStatus('포맷팅된 코드를 클립보드로 복사했습니다.');
} catch (e) {
console.error('[exlnprk] copy error:', e);
setStatus('클립보드 복사에 실패했습니다.', true);
}
}
// -------------------- Dialog open/close --------------------
let openerForFocus = null;
function openDialog() {
openerForFocus = document.activeElement;
dlg.showModal();
setTimeout(() => ta.focus(), 0);
setStatus('팁: Ctrl+Enter=포맷, Ctrl+Shift+C=복사, Esc=닫기');
}
function closeDialog() {
dlg.close();
setStatus('');
if (openerForFocus && typeof openerForFocus.focus === 'function') openerForFocus.focus();
else openBtn.focus();
}
// -------------------- Events --------------------
closeX.addEventListener('click', closeDialog);
closeBtn2.addEventListener('click', closeDialog);
ta.addEventListener('paste', (ev) => {
if (!autoTabs.checked) return;
try {
const data = ev.clipboardData?.getData('text');
if (typeof data === 'string') {
ev.preventDefault();
const converted = tabsToSpaces(data, getIndent());
const { selectionStart, selectionEnd, value } = ta;
const before = value.slice(0, selectionStart);
const after = value.slice(selectionEnd);
ta.value = before + converted + after;
const pos = before.length + converted.length;
ta.setSelectionRange(pos, pos);
}
} catch {}
});
dlg.addEventListener('keydown', async (e) => {
if (e.key === 'Escape') { e.preventDefault(); closeDialog(); return; }
const meta = e.ctrlKey || e.metaKey;
if (meta && e.key.toLowerCase() === 'enter') { e.preventDefault(); const res = await formatCode(); if (res.ok) setStatus('포맷팅 완료'); }
if (meta && e.shiftKey && e.key.toLowerCase() === 'c') { e.preventDefault(); await copyFormatted(); }
});
fmtBtn.addEventListener('click', async () => {
const res = await formatCode();
if (res.ok) setStatus('포맷팅 완료');
});
copyBtn.addEventListener('click', async () => { await copyFormatted(); });
window.addEventListener('keydown', (e) => {
if (e.altKey && e.shiftKey && e.key.toLowerCase() === 'm') {
e.preventDefault();
if (dlg.open) closeDialog(); else openDialog();
}
});
['keydown', 'keyup', 'keypress'].forEach(type => {
ta.addEventListener(type, (e) => e.stopPropagation());
});
langSel.addEventListener('change', () => {
try { localStorage.setItem(LS_KEYS.lastLang, langSel.value); } catch {}
delBtn.disabled = !isCustomValue(langSel.value);
});
addBtn.addEventListener('click', () => {
const name = (prompt('추가할 언어 이름을 입력하세요 (예: Kotlin)') || '').trim();
if (!name) return;
const exists = [...langSel.options].some(o => o.textContent.toLowerCase() === name.toLowerCase());
if (exists) { setStatus('이미 존재하는 언어입니다.'); return; }
const list = loadCustomLangs(); list.push(name); saveCustomLangs(list);
populateLangs();
langSel.value = customValue(name);
try { localStorage.setItem(LS_KEYS.lastLang, langSel.value); } catch {}
delBtn.disabled = false;
setStatus(`언어 추가: ${name}`);
});
delBtn.addEventListener('click', () => {
const v = langSel.value;
if (!isCustomValue(v)) { setStatus('기본 언어는 삭제할 수 없습니다.'); return; }
const name = v.slice('custom:'.length);
if (!confirm(`"${name}" 언어를 삭제할까요?`)) return;
const list = loadCustomLangs().filter(n => n !== name);
saveCustomLangs(list);
populateLangs();
delBtn.disabled = !isCustomValue(langSel.value);
setStatus(`언어 삭제: ${name}`);
});
delBtn.disabled = !isCustomValue(langSel.value);
// -------------------- Hold-to-drag(500ms) on FAB --------------------
(function installHoldDragButton(btn, onActivate, opts = {}) {
const holdMs = opts.holdMs ?? 500;
const storageKey = LS_KEYS.btnPos;
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
// 수정: saved.right -> saved.left
if (saved && saved.left && saved.bottom) {
// 수정: btn.style.right -> btn.style.left
btn.style.left = saved.left;
btn.style.bottom = saved.bottom;
}
} catch {}
let down = false;
let dragging = false;
let holdTimer = null;
// 수정: startRight -> startLeft
let startX = 0, startY = 0, startLeft = 0, startBottom = 0;
let pointerId = null;
function savePos() {
try {
localStorage.setItem(storageKey, JSON.stringify({
// 수정: right -> left
left: btn.style.left || '',
bottom: btn.style.bottom || ''
}));
} catch {}
}
function startDragVisual() {
dragging = true;
btn.classList.add('exlnprk-dragging');
try { setStatus('버튼 이동 모드: 드래그로 위치 조정'); } catch {}
}
function endDragVisual() {
dragging = false;
btn.classList.remove('exlnprk-dragging');
try { setStatus(''); } catch {}
}
function onPointerDown(e) {
if (typeof e.button === 'number' && e.button !== 0) return;
down = true;
dragging = false;
pointerId = e.pointerId;
btn.setPointerCapture?.(pointerId);
const cs = getComputedStyle(btn);
startX = e.clientX;
startY = e.clientY;
// 수정: cs.right -> cs.left, startRight -> startLeft
startLeft = parseInt(cs.left, 10) || 0;
startBottom = parseInt(cs.bottom, 10) || 0;
holdTimer = setTimeout(() => {
if (!down) return;
startDragVisual();
}, holdMs);
e.preventDefault();
e.stopPropagation();
}
function onPointerMove(e) {
if (!down || !dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// 수정: right 계산 -> left 계산
btn.style.left = Math.max(0, startLeft + dx) + 'px';
btn.style.bottom = Math.max(0, startBottom - dy) + 'px';
}
function onPointerUp(e) {
if (!down) return;
down = false;
clearTimeout(holdTimer);
holdTimer = null;
if (dragging) {
endDragVisual();
savePos();
e.preventDefault();
e.stopPropagation();
return;
}
if (typeof onActivate === 'function') onActivate();
e.preventDefault();
e.stopPropagation();
}
function onPointerCancel() {
if (!down) return;
down = false;
clearTimeout(holdTimer);
holdTimer = null;
if (dragging) {
endDragVisual();
savePos();
}
}
btn.addEventListener('pointerdown', onPointerDown);
btn.addEventListener('pointermove', onPointerMove);
btn.addEventListener('pointerup', onPointerUp);
btn.addEventListener('pointercancel', onPointerCancel);
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
}, true);
})(openBtn, openDialog, { holdMs: 500 });
// -------------------- Keyboard nudge(Alt+Shift+Arrows) --------------------
(function enablePositionNudge(btn) {
function save() {
try {
localStorage.setItem(LS_KEYS.btnPos, JSON.stringify({
// 수정: right -> left
left: btn.style.left || '',
bottom: btn.style.bottom || ''
}));
} catch {}
}
window.addEventListener('keydown', (e) => {
if (!(e.altKey && e.shiftKey)) return;
const step = 4;
const cs = getComputedStyle(btn);
// 수정: curRight -> curLeft
const curLeft = parseInt(cs.left, 10) || 0;
const curBottom = parseInt(cs.bottom, 10) || 0;
if (e.key === 'ArrowUp') { btn.style.bottom = (curBottom + step) + 'px'; save(); e.preventDefault(); }
else if (e.key === 'ArrowDown') { btn.style.bottom = Math.max(0, curBottom - step) + 'px'; save(); e.preventDefault(); }
// 수정: right -> left 로직 변경
else if (e.key === 'ArrowLeft') { btn.style.left = Math.max(0, curLeft - step) + 'px'; save(); e.preventDefault(); }
else if (e.key === 'ArrowRight') { btn.style.left = (curLeft + step) + 'px'; save(); e.preventDefault(); }
// 수정: right, left 스타일 초기화
else if (e.key.toLowerCase() === 'r') { btn.style.left = ''; btn.style.right = ''; btn.style.bottom = ''; localStorage.removeItem(LS_KEYS.btnPos); e.preventDefault(); }
});
})(openBtn);
})();