디시인사이드 게시글 미리보기

디시인사이드 갤러리에서 게시글 제목에 마우스를 올리면 미리보기 팝업을 표시합니다. (다크 모드 지원)

// ==UserScript==
// @name         디시인사이드 게시글 미리보기
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  디시인사이드 갤러리에서 게시글 제목에 마우스를 올리면 미리보기 팝업을 표시합니다. (다크 모드 지원)
// @author       guvno
// @match        https://gall.dcinside.com/*/board/lists*
// @match        https://gall.dcinside.com/board/lists*
// @grant        GM_xmlhttpRequest
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // 스타일 추가 (다크 모드 지원)
    const style = document.createElement('style');
    style.textContent = `
        .preview-popup {
            position: absolute;
            width: 300px;
            background-color: #252525; /* 다크 모드 배경색 */
            border: 1px solid #666; /* 다크 모드 테두리 색상 */
            color: #eee; /* 다크 모드 글자 색상 */
            padding: 10px;
            box-shadow: 0 0 10px rgba(0,0,0,0.5); /* 다크 모드 그림자 */
            z-index: 9999;
            max-height: 300px;
            overflow-y: auto;
            display: none;
            transition: all 0.3s ease;
            cursor: pointer;
            box-sizing: border-box;
            word-break: break-word;
        }
        .preview-popup.expanded {
            width: 90vw;
            max-height: 90vh;
            overflow-y: auto;
        }
        .preview-popup img {
            max-width: 100%;
            max-height: 250px;
            height: auto;
            display: block;
            margin: 10px 0;
            pointer-events: none;
        }
        .preview-popup.expanded img {
            max-height: 500px;
        }
        .preview-popup a {
            color: #459aff;
        }
    `;
    document.head.appendChild(style);

    // 단일 팝업 요소 생성
    const popup = document.createElement('div');
    popup.className = 'preview-popup';
    document.body.appendChild(popup);

    let hideTimeout = null;
    let currentLink = null;
    let isExpanded = false;

    // 캐싱을 위한 Map 객체
    const contentCache = new Map();

    // 동시에 진행되는 요청 수를 제한하기 위한 변수
    const MAX_CONCURRENT_REQUESTS = 5;
    let currentRequests = 0;
    const requestQueue = [];

    // 디바운스 타임 설정 (밀리초)
    const DEBOUNCE_DELAY = 100;

    // 게시글 내용 가져오기 함수
    function fetchPostContent(url) {
        return new Promise((resolve, reject) => {
            if (contentCache.has(url)) {
                resolve(contentCache.get(url));
                return;
            }

            requestQueue.push({ url, resolve, reject });
            processQueue();
        });
    }

    // 요청 큐 처리 함수
    function processQueue() {
        if (currentRequests >= MAX_CONCURRENT_REQUESTS || requestQueue.length === 0) {
            return;
        }

        const { url, resolve, reject } = requestQueue.shift();
        currentRequests++;

        GM_xmlhttpRequest({
            method: 'GET',
            url: url,
            onload: function(response) {
                currentRequests--;
                try {
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(response.responseText, 'text/html');
                    const contentElement = doc.querySelector('.writing_view_box') || doc.querySelector('.view_content_wrap');
                    let content = contentElement ? contentElement.innerHTML : '내용을 불러올 수 없습니다.';

                    // 캐시에 저장
                    contentCache.set(url, content);
                    resolve(content);
                } catch (error) {
                    reject('내용 파싱 오류: ' + error);
                }
                processQueue();
            },
            onerror: function(error) {
                currentRequests--;
                reject('오류: ' + error);
                processQueue();
            }
        });
    }

    // 팝업 위치 설정
    function positionPopup(link) {
        const rect = link.getBoundingClientRect();
        const scrollY = window.scrollY || window.pageYOffset;
        const scrollX = window.scrollX || window.pageXOffset;
        popup.style.top = `${scrollY + rect.top + 20}px`;
        popup.style.left = `${scrollX + rect.left}px`;
    }

    // 디바운스를 위한 타이머 저장
    const debounceTimers = new Map();

    // 링크에 이벤트 리스너 추가
    const titleLinks = document.querySelectorAll('.gall_tit a, .ub-content a.subject');
    titleLinks.forEach(link => {
        link.addEventListener('mouseenter', function(e) {
            if (hideTimeout) {
                clearTimeout(hideTimeout);
                hideTimeout = null;
            }

            if (isExpanded) {
                return;
            }

            currentLink = this;

            if (debounceTimers.has(this)) {
                clearTimeout(debounceTimers.get(this));
            }

            const timer = setTimeout(async () => {
                const url = this.href;
                try {
                    const content = await fetchPostContent(url);
                    popup.innerHTML = content;

                    const images = popup.querySelectorAll('img');
                    let imagesLoaded = 0;
                    const totalImages = images.length;

                    if (totalImages === 0) {
                        positionPopup(this);
                        popup.style.display = 'block';
                        return;
                    }

                    images.forEach(img => {
                        if (img.complete) {
                            imagesLoaded++;
                            if (imagesLoaded === totalImages) {
                                positionPopup(this);
                                popup.style.display = 'block';
                            }
                        } else {
                            img.addEventListener('load', () => {
                                imagesLoaded++;
                                if (imagesLoaded === totalImages) {
                                    positionPopup(this);
                                    popup.style.display = 'block';
                                }
                            });
                            img.addEventListener('error', () => {
                                imagesLoaded++;
                                if (imagesLoaded === totalImages) {
                                    positionPopup(this);
                                    popup.style.display = 'block';
                                }
                            });
                        }
                    });

                    popup.style.display = 'block';
                } catch (error) {
                    console.error('미리보기를 불러오는 중 오류 발생:', error);
                    popup.innerHTML = '내용을 불러올 수 없습니다.';
                    positionPopup(this);
                    popup.style.display = 'block';
                    isExpanded = false;
                    popup.classList.remove('expanded');
                }
            }, DEBOUNCE_DELAY);

            debounceTimers.set(this, timer);
        });

        link.addEventListener('mouseleave', function(e) {
            if (debounceTimers.has(this)) {
                clearTimeout(debounceTimers.get(this));
                debounceTimers.delete(this);
            }

            hideTimeout = setTimeout(() => {
                if (!popup.matches(':hover') && !isExpanded) {
                    popup.style.display = 'none';
                    popup.innerHTML = '';
                    currentLink = null;
                }
            }, 300);
        });
    });

    // 팝업에 이벤트 리스너 추가
    popup.addEventListener('mouseenter', function() {
        if (hideTimeout) {
            clearTimeout(hideTimeout);
            hideTimeout = null;
        }
    });

    popup.addEventListener('mouseleave', function() {
        if (!isExpanded) {
            popup.style.display = 'none';
            popup.innerHTML = '';
            currentLink = null;
        }
    });

    // 팝업 클릭 시 크기 토글
    popup.addEventListener('click', function(e) {
        e.stopPropagation();
        isExpanded = !isExpanded;
        if (isExpanded) {
            popup.classList.add('expanded');
            if (currentLink) {
                positionPopup(currentLink);
            }
        } else {
            popup.classList.remove('expanded');
        }
    });

    // 외부 클릭 시 팝업 숨기기 (확장된 상태에서도 동작)
    document.addEventListener('click', function(e) {
        if (isExpanded && currentLink && !popup.contains(e.target) && !currentLink.contains(e.target)) {
            popup.style.display = 'none';
            popup.innerHTML = '';
            popup.classList.remove('expanded');
            isExpanded = false;
            currentLink = null;
        }
    });

    // 팝업이 확장된 상태에서는 스크롤로 인해 팝업이 사라지지 않도록 수정
    window.addEventListener('scroll', () => {
        if (popup.style.display === 'block' && !isExpanded) {
            popup.style.display = 'none';
            popup.innerHTML = '';
            currentLink = null;
        }
    });

    window.addEventListener('resize', () => {
        if (popup.style.display === 'block' && currentLink) {
            positionPopup(currentLink);
        }
    });

    popup.addEventListener('wheel', function(e) {
        if (isExpanded) {
            e.stopPropagation();
        }
    }, { passive: false });

})();