您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
개드립 게시물 미리보기 도구
// ==UserScript== // @name DogDrip Preview tool // @version 1.0.1 // @author TKC // @description 개드립 게시물 미리보기 도구 // @namespace http://tampermonkey.net/ // @match https://www.dogdrip.net/* // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; // ——— 설정 상수 ——— const CONFIG = { HOVER_DELAY: 700, // 호버 후 미리보기 표시까지 대기 시간(ms) HIDE_DELAY: 200, // 마우스 벗어난 후 미리보기 숨김까지 대기 시간(ms) PREVIEW_SIZE: { minWidth: '300px', minHeight: '200px', maxWidth: '800px', maxHeight: '600px', defaultWidth: '30vw', defaultHeight: '45vh' }, Z_INDEX: 10000, ANIMATION_DURATION: 300 // 애니메이션 지속 시간(ms) }; // ——— 유틸리티 함수 ——— const Util = { createElement(tag, attributes = {}, styles = {}) { const element = document.createElement(tag); Object.assign(element, attributes); Object.assign(element.style, styles); return element; }, setStyles(element, styles) { Object.assign(element.style, styles); }, addEventListeners(element, events) { for (const [event, handler] of Object.entries(events)) { element.addEventListener(event, handler); } } }; // ——— 프리뷰 박스 모듈 ——— const PreviewBox = (function() { let previewBox; let hideTimer; function create() { if (!previewBox) { previewBox = Util.createElement('div', {}, { position: 'fixed', zIndex: CONFIG.Z_INDEX, background: '#fff', border: '1px solid #ccc', boxShadow: '0 2px 8px rgba(0,0,0,0.2)', padding: '0px', boxSizing: 'border-box', minWidth: CONFIG.PREVIEW_SIZE.minWidth, minHeight: CONFIG.PREVIEW_SIZE.minHeight, maxWidth: CONFIG.PREVIEW_SIZE.maxWidth, maxHeight: CONFIG.PREVIEW_SIZE.maxHeight, width: CONFIG.PREVIEW_SIZE.defaultWidth, height: CONFIG.PREVIEW_SIZE.defaultHeight, overflow: 'hidden', display: 'none', transition: `all ${CONFIG.ANIMATION_DURATION}ms ease-in-out` }); document.body.appendChild(previewBox); } return previewBox; } function adjustSize() { if (!previewBox) return; const vw = window.innerWidth; const vh = window.innerHeight; let width = Math.min(vw * 0.8, parseInt(CONFIG.PREVIEW_SIZE.maxWidth)); let height = Math.min(vh * 0.7, parseInt(CONFIG.PREVIEW_SIZE.maxHeight)); width = Math.max(width, parseInt(CONFIG.PREVIEW_SIZE.minWidth)); height = Math.max(height, parseInt(CONFIG.PREVIEW_SIZE.minHeight)); Util.setStyles(previewBox, { width: `${width}px`, height: `${height}px` }); } // ——— 프리뷰 박스 위치 설정 ——— function position(link) { if (!previewBox) create(); const rect = link.getBoundingClientRect(); // 1) 크기 조정 adjustSize(); // 2) 실제 크기 측정용: 잠시 보이게 했다가 숨김 previewBox.style.visibility = 'hidden'; previewBox.style.display = 'block'; const boxWidth = previewBox.offsetWidth; const boxHeight = previewBox.offsetHeight; previewBox.style.display = 'none'; previewBox.style.visibility = 'visible'; const viewportW = window.innerWidth; const viewportH = window.innerHeight; // 3) X 좌표 계산 let left = rect.left; if (left + boxWidth > viewportW) { left = Math.max(0, viewportW - boxWidth); } // 4) Y 좌표: 아래에 공간 있으면 아래, 아니면 위 const spaceBelow = viewportH - (rect.bottom + 5); const top = spaceBelow >= boxHeight ? rect.bottom + 5 : rect.top - boxHeight - 5; // 5) 한 번에 위치 및 보이기 Object.assign(previewBox.style, { top: `${top}px`, left: `${left}px`, opacity: '0', display: 'block' }); // 6) fade-in requestAnimationFrame(() => { previewBox.style.opacity = '1'; }); } function show(content) { if (!previewBox) create(); previewBox.innerHTML = content; previewBox.style.display = 'block'; } // ——— 프리뷰 박스 숨김 예약 ——— function scheduleHide() { clearTimeout(hideTimer); hideTimer = setTimeout(() => { if (!previewBox) return; // 페이드 아웃 Util.setStyles(previewBox, { opacity: '0' }); // 애니메이션 끝나면 DOM에서 완전 제거 setTimeout(() => { remove(); }, CONFIG.ANIMATION_DURATION); }, CONFIG.HIDE_DELAY); } function cancelHide() { clearTimeout(hideTimer); } function remove() { if (previewBox && previewBox.parentNode) { previewBox.parentNode.removeChild(previewBox); } previewBox = null; } return { create, adjustSize, position, show, scheduleHide, cancelHide, remove, getElement: () => previewBox }; })(); const previewBox = PreviewBox.create(); window.addEventListener('resize', PreviewBox.adjustSize); let hoverTimer, progressBar; function showPreview(link) { const rawHref = link.getAttribute('href'); if (!rawHref) { PreviewBox.show('<div style="padding:16px;font-size:14px;color:red;text-align:center;">미리보기 로드 실패</div>'); return; } const url = new URL(rawHref, location.origin).href; PreviewBox.position(link); PreviewBox.show(` <div style="display:flex;justify-content:center;align-items:center;height:100%;width:100%;"> <div style="text-align:center;"> <div style="margin-bottom:10px;font-size:14px;">로딩 중...</div> <div style="width:50px;height:50px;border:5px solid #f3f3f3;border-top:5px solid #3498db;border-radius:50%;animation:spin 1s linear infinite;margin:0 auto;"></div> </div> </div> <style> @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } </style> `); fetchAndRenderPreview(url); } function fetchAndRenderPreview(url) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); fetch(url, { signal: controller.signal }) .then(response => { clearTimeout(timeoutId); if (!response.ok) throw new Error(`HTTP 오류: ${response.status}`); return response.text(); }) .then(html => { const doc = new DOMParser().parseFromString(html, 'text/html'); const snippet = ContentProcessor.extractArticleContent(doc); if (!snippet) throw new Error('본문을 찾을 수 없습니다'); const styles = ContentProcessor.extractStyles(doc); const scripts = ContentProcessor.extractScripts(doc, url); const iframe = ContentProcessor.renderIframe(url, snippet, styles, scripts); const box = PreviewBox.getElement(); box.innerHTML = ''; box.appendChild(iframe); }) .catch(err => { console.error('미리보기 로드 오류:', err); let msg = '미리보기 로드 중 오류'; if (err.name === 'AbortError') msg = '요청 시간 초과. 네트워크 연결을 확인해주세요.'; else if (err instanceof TypeError) msg = '네트워크 오류. 인터넷 연결을 확인해주세요.'; else msg = `미리보기 로드 중 오류: ${err.message}`; PreviewBox.show(`<div style="padding:16px;font-size:14px;color:red;text-align:center;">${msg}</div>`); }); } const ContentProcessor = { extractArticleContent(doc) { const wrappers = Array.from(doc.querySelectorAll('div.ed.article-wrapper')); const art = wrappers.find(w => w.querySelector('#comment_end')); if (!art) return null; let html = ''; let copying = false; art.childNodes.forEach(n => { if (n.nodeType === Node.ELEMENT_NODE && n.classList.contains('inner-container')) { copying = true; } if (copying && n.outerHTML) html += n.outerHTML; if (n.nodeType === Node.ELEMENT_NODE && n.id === 'comment_end') { copying = false; } }); html = html.replace(/<div class="ed comment-form">[\s\S]*?<\/div>\s*<\/form><\/div>/g, ''); html = html.replace(/<a[^>]*>\s*댓글\s*<\/a>/g,''); return html; }, extractStyles(doc) { return Array.from(doc.querySelectorAll('link[rel="stylesheet"], style')) .map(el => el.outerHTML).join('\n'); }, extractScripts(doc, base) { return Array.from(doc.querySelectorAll('script')).map(el => { if (el.src) { const abs = new URL(el.getAttribute('src'), base).href; return `<script src="${abs}"></script>`; } else { return `<script>${el.innerHTML}</script>`; } }).join('\n'); }, renderIframe(url, content, styles, scripts) { return Util.createElement('iframe', { srcdoc: ` <!DOCTYPE html> <html> <head> <base href="${url}"> <style> html, body { height:100%; margin:0; padding:0; overflow:hidden; background:#fff; } #__preview-wrapper { height:100%; min-height:100%; overflow:auto; overscroll-behavior:contain; } </style> ${styles} ${scripts} </head> <body> <div id="__preview-wrapper"> ${content} </div> </body> </html>`.trim() }, { width: '100%', height: '100%', border: 'none', display: 'block' }); } }; const ProgressBar = (function() { let progressBar; function create(link) { remove(); const rect = link.getBoundingClientRect(); progressBar = Util.createElement('div', {}, { position: 'absolute', top: `${window.scrollY + rect.bottom + 2}px`, left: `${window.scrollX + rect.left}px`, width: '0px', height: '3px', background: 'linear-gradient(to right, #4CAF50, #2196F3)', borderRadius: '2px', transition: `width ${CONFIG.HOVER_DELAY}ms cubic-bezier(0.4, 0, 0.2, 1)`, zIndex: CONFIG.Z_INDEX, pointerEvents: 'none', boxShadow: '0 0 5px rgba(33, 150, 243, 0.5)' }); document.body.appendChild(progressBar); progressBar.offsetHeight; requestAnimationFrame(() => { if (progressBar) progressBar.style.width = `${rect.width}px`; }); } function remove() { if (progressBar && progressBar.parentNode) { progressBar.parentNode.removeChild(progressBar); } progressBar = null; } return { create, remove }; })(); const EventHandler = { onLeave() { clearTimeout(hoverTimer); ProgressBar.remove(); PreviewBox.scheduleHide(); }, onEnter(e) { const link = e.currentTarget; if (progressBar) return; ProgressBar.create(link); hoverTimer = setTimeout(() => { showPreview(link); ProgressBar.remove(); }, CONFIG.HOVER_DELAY); }, onPreviewEnter() { PreviewBox.cancelHide(); }, onPreviewLeave() { PreviewBox.scheduleHide(); }, initEventListeners() { // 1) 제목 링크 (span.ed.title-link의 부모 <a>) document.querySelectorAll('span.ed.title-link').forEach(span => { const a = span.parentElement; if (a && a.tagName === 'A') { Util.addEventListeners(a, { mouseenter: this.onEnter, mouseleave: this.onLeave }); } }); // 2) 게시판 리스트 링크 document.querySelectorAll('.ed.board-list > ul:not(.pagination) > li > a') .forEach(a => { Util.addEventListeners(a, { mouseenter: this.onEnter, mouseleave: this.onLeave }); }); const addPreviewBoxListeners = () => { const box = PreviewBox.getElement(); if (box) { Util.addEventListeners(box, { mouseenter: this.onPreviewEnter, mouseleave: this.onPreviewLeave }); } }; const observer = new MutationObserver(mutations => { mutations.forEach(m => { if (m.type === 'childList' && m.addedNodes.length) { addPreviewBoxListeners(); } }); }); observer.observe(document.body, { childList: true, subtree: true }); } }; EventHandler.initEventListeners(); window.addEventListener('resize', () => { PreviewBox.adjustSize(); const box = PreviewBox.getElement(); if (box && box.style.display !== 'none') { const hoverEl = document.querySelector(':hover'); if (!hoverEl) return; let link; // 1) board-list 아이템인 <a> if ( hoverEl.matches('.ed.board-list > ul:not(.pagination) > li > a') ) { link = hoverEl; } // 2) title-link <span> 위로 hover 됐을 때는 부모 <a> else if ( hoverEl.matches('span.ed.title-link') && hoverEl.parentElement.tagName === 'A' ) { link = hoverEl.parentElement; } if (link) { PreviewBox.position(link); } } }); })();