Last active
July 10, 2025 12:54
-
-
Save lunamoth/e1b57338f5f9f8fe440415fc6e9544cf to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name 전체 페이지 스크린샷 (Shift+Ctrl+Alt+S) | |
// @namespace http://tampermonkey.net/ | |
// @version 1.1 | |
// @description Shift+Ctrl+Alt+S 를 눌러 전체 페이지를 캡처합니다. | |
// @author Gemini & lunamoth | |
// @match *://*/* | |
// @grant GM_addStyle | |
// @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
const userConfig = { | |
downloadKey: 's', | |
imageType: 'image/png', | |
captureScale: window.devicePixelRatio || 1, | |
captureDelay: 0, | |
filenameTemplate: '{YY}{MM}{DD}_{HH}{mm}_{host}_{title}', | |
maxTitleLength: 50, | |
uiStyles: { | |
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', | |
fontSize: '16px', | |
fontWeight: '500', | |
textColor: 'rgba(255, 255, 255, 0.95)', | |
padding: '16px 24px', | |
borderRadius: '14px', | |
border: '1px solid rgba(255, 255, 255, 0.1)', | |
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)', | |
normalBackground: 'linear-gradient(135deg, rgba(10, 132, 255, 0.7), rgba(48, 209, 88, 0.7))', | |
errorBackground: 'rgba(255, 59, 48, 0.7)', | |
} | |
}; | |
const CLASSNAMES = Object.freeze({ | |
INDICATOR: 'screenshot-indicator', | |
SHOW: 'show', | |
ERROR: 'error' | |
}); | |
class DomUtils { | |
static createAndAppend(tagName, parent, options = {}) { | |
const el = document.createElement(tagName); | |
Object.assign(el, options); | |
parent.appendChild(el); | |
return el; | |
} | |
static remove(element) { | |
element?.remove(); | |
} | |
static addStyle(css) { | |
GM_addStyle(css); | |
} | |
} | |
class UiService { | |
#config; | |
#element = null; | |
#hideTimeoutId = null; | |
constructor(config) { | |
this.#config = config; | |
} | |
init() { | |
const { uiStyles } = this.#config; | |
const { INDICATOR, SHOW, ERROR } = CLASSNAMES; | |
const css = ` | |
.${INDICATOR} { | |
position: fixed; | |
top: 30px; | |
left: 50%; | |
transform: translateX(-50%); | |
z-index: 9999999; | |
text-align: center; | |
opacity: 0; | |
visibility: hidden; | |
transition: opacity .3s ease, visibility 0s .3s; | |
backdrop-filter: blur(16px) saturate(180%); | |
-webkit-backdrop-filter: blur(16px) saturate(180%); | |
font-family: ${uiStyles.fontFamily}; | |
font-size: ${uiStyles.fontSize}; | |
font-weight: ${uiStyles.fontWeight}; | |
color: ${uiStyles.textColor}; | |
padding: ${uiStyles.padding}; | |
border-radius: ${uiStyles.borderRadius}; | |
border: ${uiStyles.border}; | |
box-shadow: ${uiStyles.boxShadow}; | |
background: ${uiStyles.normalBackground}; | |
} | |
.${INDICATOR}.${SHOW} { | |
opacity: 1; | |
visibility: visible; | |
transition-delay: 0s; | |
} | |
.${INDICATOR}.${ERROR} { | |
background: ${uiStyles.errorBackground}; | |
}`; | |
DomUtils.addStyle(css); | |
} | |
#ensureElement() { | |
if (!this.#element) { | |
this.#element = DomUtils.createAndAppend('div', document.body, { | |
className: CLASSNAMES.INDICATOR | |
}); | |
} | |
} | |
show(message, isError = false) { | |
this.#ensureElement(); | |
clearTimeout(this.#hideTimeoutId); | |
this.#element.textContent = message; | |
this.#element.classList.remove(CLASSNAMES.ERROR); | |
if (isError) { | |
this.#element.classList.add(CLASSNAMES.ERROR); | |
} | |
this.#element.classList.add(CLASSNAMES.SHOW); | |
} | |
hide(delay) { | |
clearTimeout(this.#hideTimeoutId); | |
this.#hideTimeoutId = setTimeout(() => { | |
this.#element?.classList.remove(CLASSNAMES.SHOW); | |
}, delay); | |
} | |
hideImmediately() { | |
this.#element?.classList.remove(CLASSNAMES.SHOW); | |
} | |
} | |
class FilenameService { | |
#config; | |
constructor(config) { | |
this.#config = config; | |
} | |
#sanitize = (str) => str.replace(/[\\/:*?"<>|]/g, '_').trim(); | |
#truncate = (str, len) => (str.length > len ? str.substring(0, len) + '...' : str); | |
generate() { | |
const now = new Date(); | |
const pad = (num) => String(num).padStart(2, '0'); | |
const { filenameTemplate, maxTitleLength, imageType } = this.#config; | |
const title = document.title || 'NoTitle'; | |
const parts = { | |
YYYY: now.getFullYear(), | |
YY: String(now.getFullYear()).slice(-2), | |
MM: pad(now.getMonth() + 1), | |
DD: pad(now.getDate()), | |
HH: pad(now.getHours()), | |
mm: pad(now.getMinutes()), | |
ss: pad(now.getSeconds()), | |
host: this.#sanitize(window.location.hostname), | |
title: this.#truncate(this.#sanitize(title), maxTitleLength) | |
}; | |
const basename = filenameTemplate.replace(/\{(\w+)\}/g, (_, key) => parts[key] ?? ''); | |
const extension = imageType.split('/')[1]; | |
return `${basename}.${extension}`; | |
} | |
} | |
class ScreenshotService { | |
#config; | |
#filenameService; | |
constructor(config, filenameService) { | |
this.#config = config; | |
this.#filenameService = filenameService; | |
} | |
#getOptions = () => ({ | |
useCORS: true, | |
scale: this.#config.captureScale, | |
scrollX: -window.scrollX, | |
scrollY: -window.scrollY, | |
windowWidth: document.documentElement.scrollWidth, | |
windowHeight: document.documentElement.scrollHeight, | |
}); | |
async capture() { | |
return await html2canvas(document.documentElement, this.#getOptions()); | |
} | |
download(canvas) { | |
const { imageType } = this.#config; | |
const dataUrl = canvas.toDataURL(imageType); | |
const filename = this.#filenameService.generate(); | |
const link = DomUtils.createAndAppend('a', document.body, { | |
href: dataUrl, | |
download: filename | |
}); | |
link.click(); | |
DomUtils.remove(link); | |
} | |
} | |
class AppController { | |
#config; | |
#uiService; | |
#screenshotService; | |
#isCapturing = false; | |
constructor({ config, uiService, screenshotService }) { | |
this.#config = config; | |
this.#uiService = uiService; | |
this.#screenshotService = screenshotService; | |
} | |
#handleShortcut = (event) => { | |
const { downloadKey } = this.#config; | |
if (event.shiftKey && event.ctrlKey && event.altKey && event.key.toLowerCase() === downloadKey) { | |
event.preventDefault(); | |
this.#executeScreenshot(); | |
} | |
}; | |
#executeScreenshot = async () => { | |
if (this.#isCapturing) { | |
this.#uiService.show('이미 캡처가 진행 중입니다.', true); | |
this.#uiService.hide(1500); | |
return; | |
} | |
this.#isCapturing = true; | |
try { | |
if (this.#config.captureDelay > 0) { | |
this.#uiService.show(`${this.#config.captureDelay / 1000}초 후 캡처합니다...`); | |
await new Promise(resolve => setTimeout(resolve, this.#config.captureDelay)); | |
} | |
this.#uiService.show('스크린샷 생성 중...'); | |
await new Promise(resolve => requestAnimationFrame(resolve)); | |
this.#uiService.hideImmediately(); | |
const canvas = await this.#screenshotService.capture(); | |
this.#screenshotService.download(canvas); | |
this.#uiService.show('다운로드 완료!'); | |
} catch (error) { | |
this.#uiService.show('스크린샷 생성 오류!', true); | |
} finally { | |
this.#isCapturing = false; | |
this.#uiService.hide(2000); | |
} | |
}; | |
init() { | |
this.#uiService.init(); | |
window.addEventListener('keydown', this.#handleShortcut); | |
} | |
} | |
const uiService = new UiService(userConfig); | |
const filenameService = new FilenameService(userConfig); | |
const screenshotService = new ScreenshotService(userConfig, filenameService); | |
new AppController({ | |
config: userConfig, | |
uiService: uiService, | |
screenshotService: screenshotService, | |
}).init(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment