您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Allows use of code formatter in Google Sites
// ==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); })();