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