DogDrip Preview tool

개드립 게시물 미리보기 도구

// ==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);
            }
        }
    });

})();