Greasy Fork

아카라이브 듀얼스크린

아카라이브의 게시글을 게시글과 게시글 목록으로 나누어 듀얼스크린으로 변경합니다.

目前为 2025-01-03 提交的版本。查看 最新版本

// ==UserScript==
// @name         아카라이브 듀얼스크린
// @namespace    http://tampermonkey.net/
// @version      1.0.6
// @description  아카라이브의 게시글을 게시글과 게시글 목록으로 나누어 듀얼스크린으로 변경합니다.
// @icon         https://www.google.com/s2/favicons?sz=64&domain=arca.live
// @author       스토커
// @match        *://*.arca.live/b/*/*
// @exclude      *://*.arca.live/b/*/write*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // write 페이지면 아예 실행하지 않음
    if (window.location.href.includes('/write') || window.location.href.includes('/edit')) {
        return;
    }

    const style = document.createElement('style');
    style.textContent = `
        :root {
            color-scheme: light dark;
        }

        body {
            padding: 0;
            overflow: hidden;
            background: var(--color-bg-body) !important;
        }

        html, html.theme-light {
            background: var(--color-bg-root) !important;
        }

        .dual-screen-container {
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            display: flex;
            width: 100%;
            margin: 0;
            padding: 0;
            background: var(--color-bg-main);
            border: none;
            height: 100vh;
            min-height: 100vh;
            position: fixed;
            overflow: hidden;
        }

        .left-panel, .right-panel {
            height: 100%;
            position: relative;
            background: var(--color-bg-main);
            color: var(--color-text);
        }

        .left-panel {
            min-width: 200px;
            flex: 2;
            padding: 0;
            border-right: 1px solid var(--color-bd-inner);
            overflow-y: auto;
            overscroll-behavior-y: contain !important;
        }

        .left-panel .navbar {
            position: relative !important;
            width: 100%;
            background: var(--color-bg-navbar) !important;
            z-index: 100;
            color: var(--color-text-opposite) !important;
        }

        .left-panel .navbar a .nav-link {
            color: var(--color-text-opposite) !important;
        }

        .left-panel .board-title {
            position: relative !important;
            width: 100%;
            background: var(--color-bg-main) !important;
            margin: 0em 0rem 0rem 0rem !important;
            padding: 10px;
            border-bottom: 1px solid var(--color-bd-inner);
            z-index: 99;
        }

        .resize-handle {
            width: 6px;
            background: var(--color-bd-inner);
            cursor: col-resize;
            transition: background 0.3s;
            position: relative;
            min-height: 100vh;
            height: 100%;
            flex-shrink: 0;
            z-index: 1000;
        }

        .resize-handle:hover {
            background: var(--color-bd-outer);
        }

        .resize-handle.dragging {
            background: var(--color-bd-outer);
        }

        .left-panel .content-container {
            display: flex;
            flex-direction: column;
        }

        .left-panel .navbar {
            position: relative !important; /* 변경: fixed -> relative */
            width: 100%;
            background: #4f5464 !important;
            z-index: 100;
            color: #fff !important;
        }

        .left-panel .navbar a .nav-item dropdown:not {
            color: #fff !important;
        }

        .left-panel .navbar .nav-link {
            color: #fff !important;
        }

        .user-dropdown-menu {
            left: -50% !important;
        }

        .noti-dropdown-menu{
            left: -50% !important;
        }

        .left-panel .board-title {
            position: relative !important; /* 변경: fixed -> relative */
            width: 100%;
            background: #fff;
            margin: 0em 0rem 0rem 0rem !important; /* 변경: margin 값 조정 */
            padding: 10px;
            border-bottom: 1px solid #ddd;
            z-index: 99;
        }

        .left-panel .article-body {
            margin-top: 0 !important;
            padding: 10px;
        }

        .left-panel .navbar .navbar-brand svg {
            width: 21px;
            height: 21px;
        }

        .right-panel {
            min-width: 200px;
            flex: 1;
            display: flex;
            flex-direction: column;
        }

        .right-panel .right-sidebar {
            padding: 10px;
            border-bottom: 1px solid #ddd;
            margin: 0;
        }

        .right-panel .included-article-list-wrapper {
            flex: 1;
            overflow-y: auto;
            padding: 0px;
            overscroll-behavior: contain;
            touch-action: pan-y;
        }

        .right-panel .included-article-list {
            marin-top: 0 !important;
    }

        .right-panel .footer {
            padding: 10px;
            border-top: 1px solid #ddd;
        }

        .reply-form__user-info__avatar{
            width: 1.4em !important;
        }

        .board-category-wrapper {
            overflow-x: auto !important;
            white-space: nowrap !important;
            cursor: grab !important;
            user-select: none !important;
        }

        .board-category-wrapper.dragging {
            cursor: grabbing !important;
        }

        .board-category {
            display: flex !important;
            flex-wrap: nowrap !important;
            padding-bottom: 5px !important;
        }

        .board-category a {
            pointer-events: auto !important;
        }

        .board-category.dragging a {
            pointer-events: none !important;
        }

    `;

    // Add dark theme support
    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
        document.documentElement.classList.add('theme-dark');
    }

    // Watch for theme changes
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
        if (e.matches) {
            document.documentElement.classList.add('theme-dark');
        } else {
            document.documentElement.classList.remove('theme-dark');
        }
    });

    document.head.appendChild(style);

    // 설정 관리를 위한 객체 추가
    const Settings = {
        storageKey: 'arcalive_dualscreen_settings',
        defaults: {
            isSwapped: false,
            leftPanelWidth: '66.66%',
            rightPanelWidth: '33.33%'
        },

        scrollStorageKey: 'arcalive_dualscreen_scroll',

        // 스크롤 위치 저장
        saveScrollPosition(channelName, articleId, leftScroll, rightScroll) {
            try {
                const scrollData = this.loadScrollPositions();

                // 채널별 스크롤 위치 저장
                if (!scrollData.channels[channelName]) {
                    scrollData.channels[channelName] = {};
                }
                scrollData.channels[channelName].scroll = rightScroll;

                // 게시글별 스크롤 위치 저장
                if (articleId) {
                    if (!scrollData.articles) scrollData.articles = {};
                    scrollData.articles[articleId] = leftScroll;
                }

                localStorage.setItem(this.scrollStorageKey, JSON.stringify(scrollData));
            } catch (e) {
                console.error('스크롤 위치 저장 실패:', e);
            }
        },

        // 스크롤 위치 불러오기
        loadScrollPositions() {
            try {
                const saved = localStorage.getItem(this.scrollStorageKey);
                return saved ? JSON.parse(saved) : { channels: {}, articles: {} };
            } catch (e) {
                console.error('스크롤 위치 로드 실패:', e);
                return { channels: {}, articles: {} };
            }
        },

        // 특정 게시글/채널의 스크롤 위치 가져오기
        getScrollPosition(channelName, articleId) {
            const scrollData = this.loadScrollPositions();
            return {
                leftScroll: articleId ? scrollData.articles[articleId] || 0 : 0,
                rightScroll: scrollData.channels[channelName]?.scroll || 0
            };
        },

        // load() 함수 수정
        load() {
            try {
                const saved = localStorage.getItem(this.storageKey);
                if (!saved) {
                    return this.defaults;
                }
                const parsed = JSON.parse(saved);
                // 저장된 값이 있으면 기본값과 병합
                return {
                    ...this.defaults,
                    ...parsed
                };
            } catch (e) {
                console.error('설정 로드 실패:', e);
                return this.defaults;
            }
        },

        save(settings) {
            try {
                // 숫자를 백분율 문자열로 변환하여 저장
                const saveData = {
                    isSwapped: settings.isSwapped,
                    leftPanelWidth: typeof settings.leftPanelWidth === 'number' ?
                        settings.leftPanelWidth + '%' : settings.leftPanelWidth,
                    rightPanelWidth: typeof settings.rightPanelWidth === 'number' ?
                        settings.rightPanelWidth + '%' : settings.rightPanelWidth
                };
                localStorage.setItem(this.storageKey, JSON.stringify(saveData));
            } catch (e) {
                console.error('설정 저장 실패:', e);
            }
        },

        // 현재 레이아웃 상태 저장
        saveCurrentLayout(isSwapped, leftPanel, rightPanel) {
            const totalWidth = leftPanel.parentElement.offsetWidth - 6; // 핸들바 너비(6px) 제외
            const leftWidth = (leftPanel.offsetWidth / totalWidth) * 100;
            const rightWidth = (rightPanel.offsetWidth / totalWidth) * 100;

            this.save({
                isSwapped: isSwapped,
                leftPanelWidth: leftWidth,
                rightPanelWidth: rightWidth
            });
        }
    };

    function initializeDualScreen() {

        const navbar = document.querySelector('.navbar');
        const boardTitle = document.querySelector('.board-title');
        const articleWrapper = document.querySelector('.article-wrapper');
        const includedArticles = document.querySelector('.included-article-list');
        const rightSidebar = document.querySelector('.right-sidebar');
        const footer = document.querySelector('.footer');

        if (!articleWrapper || !includedArticles) return;

        // 컨테이너 생성
        const container = document.createElement('div');
        container.className = 'dual-screen-container';

        // 좌측 패널
        const leftPanel = document.createElement('div');
        leftPanel.className = 'left-panel';

        // 좌측 패널 내부 컨테이너 생성
        const contentContainer = document.createElement('div');
        contentContainer.className = 'content-container';

        // navbar와 board-title을 content-container에 추가
        if (navbar) {
            const navbarClone = navbar.cloneNode(true);
            contentContainer.appendChild(navbarClone);
        }

        if (boardTitle) {
            const boardTitleClone = boardTitle.cloneNode(true);
            contentContainer.appendChild(boardTitleClone);
        }

        // article-wrapper를 article-body div로 감싸서 추가
        const articleBody = document.createElement('div');
        articleBody.className = 'article-body';

        // Deep clone the article wrapper with all event listeners
        const clonedArticleWrapper = articleWrapper.cloneNode(true);

        // Re-attach event handlers for rating forms and share button
        const originalRateUpForm = articleWrapper.querySelector('#rateUpForm');
        const originalRateDownForm = articleWrapper.querySelector('#rateDownForm');
        const originalShareBtn = articleWrapper.querySelector('#articleShareBtn');
        const clonedRateUpForm = clonedArticleWrapper.querySelector('#rateUpForm');
        const clonedRateDownForm = clonedArticleWrapper.querySelector('#rateDownForm');
        const clonedShareBtn = clonedArticleWrapper.querySelector('#articleShareBtn');

        // 추천 버튼 이벤트 처리
        if (originalRateUpForm && clonedRateUpForm) {
            clonedRateUpForm.addEventListener('submit', (e) => {
                e.preventDefault();
                const submitEvent = new Event('submit', {
                    bubbles: true,
                    cancelable: true
                });
                originalRateUpForm.dispatchEvent(submitEvent);
                return false;
            });
        }

        // 비추천 버튼 이벤트 처리
        if (originalRateDownForm && clonedRateDownForm) {
            clonedRateDownForm.addEventListener('submit', (e) => {
                e.preventDefault();
                const submitEvent = new Event('submit', {
                    bubbles: true,
                    cancelable: true
                });
                originalRateDownForm.dispatchEvent(submitEvent);
                return false;
            });
        }

        // 공유 버튼 이벤트 처리
        if (originalShareBtn && clonedShareBtn) {
            clonedShareBtn.addEventListener('click', (e) => {
                e.preventDefault();
                const clickEvent = new MouseEvent('click', {
                    bubbles: true,
                    cancelable: true,
                    view: window
                });
                originalShareBtn.dispatchEvent(clickEvent);
                return false;
            });
        }

        // 평가 결과 동기화를 위한 MutationObserver 설정
        const ratingObserver = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                const originalCount = mutation.target;
                const targetId = originalCount.id;
                const clonedCount = clonedArticleWrapper.querySelector(`#${targetId}`);
                if (clonedCount) {
                    clonedCount.textContent = originalCount.textContent;
                }
            });
        });

        // 모든 평가 관련 요소 감시
        const ratingElements = articleWrapper.querySelectorAll('#ratingUp, #ratingDown, #ratingUpIp, #ratingDownIp');
        ratingElements.forEach(element => {
            ratingObserver.observe(element, {
                childList: true,
                characterData: true,
                subtree: true
            });
        });

        articleBody.appendChild(clonedArticleWrapper);
        contentContainer.appendChild(articleBody);

        // content-container를 left-panel에 추가
        leftPanel.appendChild(contentContainer);

        // 리사이즈 핸들
        const resizeHandle = document.createElement('div');
        resizeHandle.className = 'resize-handle';

        // 우측 패널
        const rightPanel = document.createElement('div');
        rightPanel.className = 'right-panel';

        // 우측 사이드바 wrapper
        if (rightSidebar) {
            rightPanel.appendChild(rightSidebar);
        }

        // included articles wrapper
        const includedArticlesWrapper = document.createElement('div');
        includedArticlesWrapper.className = 'included-article-list-wrapper';
        const clonedIncludedArticles = includedArticles.cloneNode(true);

        // 카테고리 스크롤 기능을 위한 이벤트 핸들러 추가
        const categoryWrappers = clonedIncludedArticles.querySelectorAll('.board-category');

        if (categoryWrappers?.length) {
            categoryWrappers.forEach(category => {
                let isDown = false;
                let startX;
                let scrollLeft;
                let dragStartTime;
                let dragStartPos;

                category.addEventListener('mousedown', (e) => {
                    isDown = true;
                    category.classList.add('dragging');
                    startX = e.pageX - category.offsetLeft;
                    scrollLeft = category.scrollLeft;
                    dragStartTime = new Date().getTime();
                    dragStartPos = e.pageX;
                });

                category.addEventListener('mousemove', (e) => {
                    if (!isDown) return;

                    const x = e.pageX - category.offsetLeft;
                    const walk = (startX - x) * 1.5; // 드래그 속도 조절
                    category.scrollLeft = scrollLeft + walk;
                });

                const stopDragging = (e) => {
                    if (!isDown) return;

                    const dragEndTime = new Date().getTime();
                    const dragEndPos = e.pageX;

                    // 드래그 시간이 짧고 이동거리가 작으면 클릭으로 처리
                    const isDrag =
                        dragEndTime - dragStartTime > 200 || // 200ms 이상
                        Math.abs(dragEndPos - dragStartPos) > 5; // 5px 이상 이동

                    if (!isDrag) {
                        const clickedLink = e.target.closest('a');
                        if (clickedLink) {
                            clickedLink.click();
                        }
                    }

                    isDown = false;
                    category.classList.remove('dragging');
                };

                category.addEventListener('mouseleave', stopDragging);
                category.addEventListener('mouseup', stopDragging);
            });
        }

        includedArticlesWrapper.appendChild(clonedIncludedArticles);
        rightPanel.appendChild(includedArticlesWrapper);

        // footer 이동
        if (footer) {
            rightPanel.appendChild(footer);
        }

        // 조립
        container.appendChild(leftPanel);
        container.appendChild(resizeHandle);
        container.appendChild(rightPanel);

        // 저장된 설정 로드하고 isPanelsSwapped 초기화
        const settings = Settings.load();
        let isPanelsSwapped = settings.isSwapped;

        // 초기 레이아웃 설정
        const applyLayout = () => {
            const settings = Settings.load();

            leftPanel.style.width = settings.leftPanelWidth;
            leftPanel.style.flex = 'none';
            rightPanel.style.width = settings.rightPanelWidth;
            rightPanel.style.flex = 'none';

            // container.innerHTML = ''; 대신 자식 요소들만 제거
            while (container.firstChild) {
                container.removeChild(container.firstChild);
            }

            // 스왑 상태에 따라 순서만 변경
            if (isPanelsSwapped) {
                container.appendChild(rightPanel);
                container.appendChild(resizeHandle);
                container.appendChild(leftPanel);
            } else {
                container.appendChild(leftPanel);
                container.appendChild(resizeHandle);
                container.appendChild(rightPanel);
            }
        };

        // 초기 레이아웃 적용
        applyLayout();

        // URL에서 채널명과 게시글 ID 추출
        const urlParts = window.location.pathname.split('/');
        const channelName = urlParts[2];
        const articleId = urlParts[3];

        // 스크롤 이벤트 처리 함수
        const handleScroll = () => {
            const leftScroll = leftPanel.scrollTop;
            const rightScroll = rightPanel.querySelector('.included-article-list-wrapper').scrollTop;
            Settings.saveScrollPosition(channelName, articleId, leftScroll, rightScroll);
        };

        // 스크롤 이벤트 리스너 등록 (디바운스 적용)
        let scrollTimeout;
        leftPanel.addEventListener('scroll', () => {
            clearTimeout(scrollTimeout);
            scrollTimeout = setTimeout(handleScroll, 100);
        });

        const rightScrollElement = rightPanel.querySelector('.included-article-list-wrapper');
        rightScrollElement.addEventListener('scroll', () => {
            clearTimeout(scrollTimeout);
            scrollTimeout = setTimeout(handleScroll, 100);
        });

        // 초기 스크롤 위치 복원
        const restoreScrollPosition = () => {
            const { leftScroll, rightScroll } = Settings.getScrollPosition(channelName, articleId);
            if (leftScroll) leftPanel.scrollTop = leftScroll;
            if (rightScroll) rightScrollElement.scrollTop = rightScroll;
        };

        // DOM이 완전히 로드된 후 스크롤 위치 복원
        setTimeout(restoreScrollPosition, 100);

        // 패널 스왑 함수 수정
        const swapPanels = () => {
            isPanelsSwapped = !isPanelsSwapped;

            // 현재 패널들의 너비를 백분율로 계산
            const totalWidth = container.offsetWidth - 6;
            const biggerWidth = Math.max(leftPanel.offsetWidth, rightPanel.offsetWidth);
            const smallerWidth = Math.min(leftPanel.offsetWidth, rightPanel.offsetWidth);

            // 더 큰 패널의 비율을 계산
            const biggerRatio = (biggerWidth / totalWidth) * 100;
            const smallerRatio = (smallerWidth / totalWidth) * 100;

            // 현재 왼쪽 패널이 더 큰지 여부 확인
            const isLeftBigger = leftPanel.offsetWidth > rightPanel.offsetWidth;

            // 스왑 후에는 비율을 반대로 적용
            if (isLeftBigger) {
                leftPanel.style.width = `${smallerRatio}%`;
                rightPanel.style.width = `${biggerRatio}%`;
            } else {
                leftPanel.style.width = `${biggerRatio}%`;
                rightPanel.style.width = `${smallerRatio}%`;
            }

            applyLayout();
            Settings.saveCurrentLayout(isPanelsSwapped, leftPanel, rightPanel);
        };

        // 더블클릭 이벤트 추가
        resizeHandle.addEventListener('dblclick', swapPanels);

        // 원래 요소 교체 및 제거
        articleWrapper.parentNode.replaceChild(container, articleWrapper);
        includedArticles.remove();
        if (rightSidebar) rightSidebar.remove();
        if (footer) footer.remove();
        if (navbar) navbar.remove();
        if (boardTitle) boardTitle.remove();

        // 리사이징 이벤트 설정
        let isResizing = false;
        let startX, startWidth;

        const handleResize = (e) => {
            if (!isResizing) return;

            const containerWidth = container.offsetWidth;
            const minWidth = 200;
            const maxWidth = containerWidth - minWidth;

            const currentX = e.pageX;
            const diffX = currentX - startX;
            const targetPanel = isPanelsSwapped ? rightPanel : leftPanel;
            const otherPanel = isPanelsSwapped ? leftPanel : rightPanel;

            let newWidth = startWidth + diffX;
            newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
            const otherWidth = containerWidth - newWidth - 6;

            // 너비 적용
            targetPanel.style.width = `${newWidth}px`;
            targetPanel.style.flex = 'none';
            otherPanel.style.width = `${otherWidth}px`;
            otherPanel.style.flex = 'none';

            // mousemove 이벤트에서 실시간으로 저장하도록 수정
            const totalWidth = container.offsetWidth - 6;
            const leftWidth = parseFloat((leftPanel.offsetWidth / totalWidth) * 100).toFixed(2);
            const rightWidth = parseFloat((rightPanel.offsetWidth / totalWidth) * 100).toFixed(2);

            Settings.save({
                isSwapped: isPanelsSwapped,
                leftPanelWidth: leftWidth + '%',
                rightPanelWidth: rightWidth + '%'
            });
        };

        resizeHandle.addEventListener('mousedown', (e) => {
            isResizing = true;
            resizeHandle.classList.add('dragging');
            startX = e.pageX;
            startWidth = (isPanelsSwapped ? rightPanel : leftPanel).offsetWidth;
        });

        document.addEventListener('mousemove', handleResize);

        // mouseup 이벤트 리스너에서는 저장 로직 제거
        document.addEventListener('mouseup', () => {
            if (isResizing) {
                isResizing = false;
                resizeHandle.classList.remove('dragging');
            }
        });

        // 윈도우 리사이즈 대응
        let resizeTimeout;
        window.addEventListener('resize', () => {
            clearTimeout(resizeTimeout);
            resizeTimeout = setTimeout(applyLayout, 100);
        });
    }

    // 페이지 로드 완료 후 실행
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initializeDualScreen);
    } else {
        initializeDualScreen();
    }
})();