Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save lunamoth/e1b57338f5f9f8fe440415fc6e9544cf to your computer and use it in GitHub Desktop.
Save lunamoth/e1b57338f5f9f8fe440415fc6e9544cf to your computer and use it in GitHub Desktop.
// ==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