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