Greasy Fork

Greasy Fork is available in English.

아카라이브 듀얼스크린

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

当前为 2025-01-03 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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