您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
전체보기/틀린문제 보기 탭 추가
// ==UserScript== // @name 문제 필터링 탭 // @license MIT // @namespace http://tampermonkey.net/ // @version 1.1 // @description 전체보기/틀린문제 보기 탭 추가 // @match https://docs.google.com/forms/d/e/*/viewscore* // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // 상수 정의 const CONSTANTS = { SELECTORS: { LIST_CONTAINER: 'div[role="list"]', WRONG_LABEL: '[aria-label="틀림"], [aria-label="Incorrect"]', RESULT_SPAN: '.result-span' }, INDICES: { DEFAULT_CAROUSEL_START: 3, WRONG_MODE_CAROUSEL_START: 4 }, TIMEOUTS: { ELEMENT_WAIT: 10000 }, MODES: { ALL: 'all', WRONG: 'wrong' } }; // CSS 색상 정의 const COLORS = { PRIMARY: '#007bff', PRIMARY_HOVER: '#0056b3', SUCCESS: '#28a745', SECONDARY: '#6c757d', INFO: '#2196F3', INFO_DARK: '#1976D2', LIGHT_BLUE: '#e3f2fd', GRAY_LIGHT: '#f5f5f5', BORDER: '#ddd' }; console.log('[문제 필터링 탭] 스크립트 시작!'); console.log('[문제 필터링 탭] 현재 URL:', window.location.href); // 비즈니스 로직 함수들 const businessLogic = { // 틀린 문제 번호 추출 extractWrongNumbers(listContainer) { const items = listContainer.children; const wrongNumbers = []; for (let i = CONSTANTS.INDICES.DEFAULT_CAROUSEL_START; i < items.length; i++) { const number = this.extractNumberFromItem(items[i]); if (number) { wrongNumbers.push(number); } } return wrongNumbers; }, // 개별 아이템에서 틀린 문제 번호 추출 extractNumberFromItem(item) { const wrongLabel = item.querySelector(CONSTANTS.SELECTORS.WRONG_LABEL); if (!wrongLabel) return null; const nextSibling = wrongLabel.nextElementSibling; if (!nextSibling) return null; const boldText = nextSibling.querySelector('span > b'); if (!boldText) return null; return boldText.textContent.replace(/\[|\]/g, ''); }, // 결과 UI 생성 및 삽입 createAndInsertResultUI(listContainer, wrongNumbers, state, updateCarousel, chipButtons) { // 기존 결과 제거 const existing = document.querySelector(CONSTANTS.SELECTORS.RESULT_SPAN); if (existing) existing.remove(); if (listContainer.children.length < CONSTANTS.INDICES.WRONG_MODE_CAROUSEL_START) { return; } const referenceElement = listContainer.children[CONSTANTS.INDICES.DEFAULT_CAROUSEL_START]; const resultElement = this.createResultElement(wrongNumbers, state, updateCarousel, chipButtons); listContainer.insertBefore(resultElement, referenceElement); }, // 결과 요소 생성 createResultElement(wrongNumbers, state, updateCarousel, chipButtons) { const resultSpan = utils.createContainer({ display: 'block', margin: '10px 0', padding: '15px', backgroundColor: COLORS.LIGHT_BLUE, borderRadius: '5px', fontWeight: 'bold', flexShrink: '0' }); resultSpan.className = 'result-span'; // 제목 추가 const title = this.createTitle(wrongNumbers.length); resultSpan.appendChild(title); // 칩 컨테이너 추가 (틀린 문제가 있는 경우) if (wrongNumbers.length > 0) { const chipContainer = this.createChipContainer(wrongNumbers, state, updateCarousel); resultSpan.appendChild(chipContainer); // chipButtons 배열 업데이트 chipButtons.length = 0; // 기존 배열 초기화 chipContainer.querySelectorAll('button').forEach(button => { chipButtons.push(button); }); } return resultSpan; }, // 제목 요소 생성 createTitle(wrongCount) { const title = document.createElement('div'); title.style.marginBottom = '10px'; title.textContent = wrongCount > 0 ? `틀린문제(${wrongCount}개):` : '틀린문제 없음'; return title; }, // 칩 컨테이너 생성 createChipContainer(wrongNumbers, state, updateCarousel) { const container = utils.createContainer({ display: 'flex', flexWrap: 'wrap', gap: '8px' }); wrongNumbers.forEach(number => { const chip = this.createChip(number, state, updateCarousel); container.appendChild(chip); }); return container; }, // 개별 칩 생성 createChip(number, state, updateCarousel) { const chip = utils.createButton(`${number}번`, { padding: '6px 12px', borderRadius: '16px', backgroundColor: COLORS.INFO, fontSize: '14px', transition: 'all 0.2s', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }); this.setupChipEvents(chip, number, state, updateCarousel); return chip; }, // 칩 이벤트 설정 setupChipEvents(chip, number, state, updateCarousel) { chip.addEventListener('mouseenter', () => { // Early return: 캐러셀이 비활성화된 경우 if (!state.carouselEnabled) { utils.applyStyles(chip, { cursor: 'not-allowed', opacity: '0.6' }); return; } // Early return: 현재 활성 칩인 경우 (변화 없음) if (parseInt(number) === state.currentIndex + 1) return; utils.applyStyles(chip, { backgroundColor: COLORS.INFO_DARK, transform: 'scale(1.05)' }); }); chip.addEventListener('mouseleave', () => { // Early return: 캐러셀이 비활성화된 경우 if (!state.carouselEnabled) { utils.applyStyles(chip, { cursor: 'pointer', opacity: '1' }); return; } // Early return: 현재 활성 칩인 경우 (변화 없음) if (parseInt(number) === state.currentIndex + 1) return; utils.applyStyles(chip, { backgroundColor: COLORS.INFO, transform: 'scale(1)' }); }); chip.addEventListener('click', () => { // Early return: 캐러셀이 비활성화된 경우 if (!state.carouselEnabled) return; updateCarousel(parseInt(number) - 1); }); } }; // 유틸리티 함수들 const utils = { // CSS 스타일 적용 헬퍼 applyStyles(element, styles) { Object.assign(element.style, styles); }, // 버튼 생성 헬퍼 createButton(text, styles = {}, clickHandler = null) { const button = document.createElement('button'); button.textContent = text; this.applyStyles(button, { padding: '8px 16px', border: 'none', borderRadius: '4px', cursor: 'pointer', color: 'white', ...styles }); // Early return: 클릭 핸들러가 없는 경우 if (!clickHandler) return button; button.addEventListener('click', clickHandler); return button; }, // 컨테이너 생성 헬퍼 createContainer(styles = {}) { const container = document.createElement('div'); this.applyStyles(container, styles); return container; } }; function waitForElement(selector, callback) { console.log('[문제 필터링 탭] 요소 대기 중:', selector); const element = document.querySelector(selector); if (element) { console.log('[문제 필터링 탭] 요소를 즉시 찾음!'); callback(element); return; } console.log('[문제 필터링 탭] 요소를 찾지 못함. MutationObserver 시작...'); const observer = new MutationObserver((mutations, obs) => { const element = document.querySelector(selector); if (element) { console.log('[문제 필터링 탭] MutationObserver가 요소를 찾음!'); obs.disconnect(); callback(element); } }); observer.observe(document.body, { childList: true, subtree: true }); // 타임아웃 setTimeout(() => { console.log('[문제 필터링 탭] 10초 경과 - 요소를 찾지 못함'); }, CONSTANTS.TIMEOUTS.ELEMENT_WAIT); } // 리스트 요소를 찾을 때까지 대기 waitForElement(CONSTANTS.SELECTORS.LIST_CONTAINER, (listContainer) => { console.log('[문제 필터링 탭] 리스트 컨테이너 찾음!'); if (!listContainer) { console.log('리스트를 찾을 수 없습니다.'); return; } // 리스트 컨테이너에 flex 적용 listContainer.style.display = 'flex'; listContainer.style.flexDirection = 'column'; listContainer.style.gap = '10px'; let currentIndex = 0; let carouselStartIndex = CONSTANTS.INDICES.DEFAULT_CAROUSEL_START; let currentMode = CONSTANTS.MODES.ALL; let wrongNumbersList = []; let chipButtons = []; let carouselEnabled = false; function updateCarousel(index) { const allChildren = Array.from(listContainer.children); const carouselItems = allChildren.slice(carouselStartIndex); // Early return: 캐러셀이 비활성화된 경우 if (!carouselEnabled) { applyFilteringOnly(allChildren); return; } // 순환 처리 if (index < 0) index = carouselItems.length - 1; if (index >= carouselItems.length) index = 0; currentIndex = index; applyCarouselMode(allChildren); updateChipStates(); } // 필터링 전용 모드 (캐러셀 비활성화) function applyFilteringOnly(allChildren) { allChildren.forEach((item, i) => { // Early return: 탭바와 결과 영역은 항상 표시 if (i < carouselStartIndex) { item.style.display = ''; resetItemStyles(item); return; } // 틀린문제 모드인 경우에만 필터링 적용 if (currentMode === CONSTANTS.MODES.WRONG) { const questionNumber = i - carouselStartIndex + 1; item.style.display = wrongNumbersList.includes(questionNumber.toString()) ? '' : 'none'; } else { item.style.display = ''; } resetItemStyles(item); }); } // 캐러셀 모드 적용 function applyCarouselMode(allChildren) { allChildren.forEach((item, i) => { // Early return: 캐러셀 시작 전 요소들 if (i < carouselStartIndex) { item.style.display = ''; item.style.position = 'relative'; item.style.opacity = '1'; item.style.flexShrink = '0'; return; } const carouselIdx = i - carouselStartIndex; const isCurrentItem = carouselIdx === currentIndex; item.style.display = isCurrentItem ? '' : 'none'; if (isCurrentItem) { item.style.position = 'relative'; item.style.opacity = '1'; item.style.flexShrink = '1'; } }); } // 아이템 스타일 초기화 헬퍼 function resetItemStyles(item) { item.style.position = 'relative'; item.style.opacity = '1'; item.style.flexShrink = ''; } function updateChipStates() { // Early return: 틀린문제 모드가 아닌 경우 if (currentMode !== CONSTANTS.MODES.WRONG) return; const currentNumber = currentIndex + 1; chipButtons.forEach((chip, idx) => { const isActive = parseInt(wrongNumbersList[idx]) === currentNumber; const styles = isActive ? { backgroundColor: COLORS.INFO_DARK, fontWeight: 'bold', transform: 'scale(1.1)' } : { backgroundColor: COLORS.INFO, fontWeight: 'normal', transform: 'scale(1)' }; utils.applyStyles(chip, styles); }); } function getNextIndex() { // Early return: 전체 모드인 경우 if (currentMode === CONSTANTS.MODES.ALL) { return currentIndex + 1; } // 틀린문제 모드: 다음 틀린 문제 찾기 const currentNumber = currentIndex + 1; for (let i = 0; i < wrongNumbersList.length; i++) { if (parseInt(wrongNumbersList[i]) > currentNumber) { return parseInt(wrongNumbersList[i]) - 1; } } // 마지막이면 처음으로 return parseInt(wrongNumbersList[0]) - 1; } function getPrevIndex() { // Early return: 전체 모드인 경우 if (currentMode === CONSTANTS.MODES.ALL) { return currentIndex - 1; } // 틀린문제 모드: 이전 틀린 문제 찾기 const currentNumber = currentIndex + 1; for (let i = wrongNumbersList.length - 1; i >= 0; i--) { if (parseInt(wrongNumbersList[i]) < currentNumber) { return parseInt(wrongNumbersList[i]) - 1; } } // 처음이면 마지막으로 return parseInt(wrongNumbersList[wrongNumbersList.length - 1]) - 1; } // 탭 바 생성 const tabBar = utils.createContainer({ display: 'flex', gap: '10px', marginBottom: '15px', padding: '10px', backgroundColor: COLORS.GRAY_LIGHT, borderRadius: '5px' }); // 전체보기 탭 const allTab = utils.createButton('전체', { backgroundColor: COLORS.PRIMARY, fontWeight: 'bold' }); // 틀린문제 보기 탭 const wrongTab = utils.createButton('틀린문제', { backgroundColor: COLORS.SECONDARY }); // 캐러셀 모드 체크박스 const carouselToggleContainer = document.createElement('label'); carouselToggleContainer.style.cssText = ` display: inline-flex; align-items: center; gap: 5px; padding: 8px; cursor: pointer; user-select: none; flex-shrink: 0; white-space: nowrap; `; const carouselCheckbox = document.createElement('input'); carouselCheckbox.type = 'checkbox'; carouselCheckbox.checked = false; carouselCheckbox.style.cssText = ` cursor: pointer; width: 16px; height: 16px; `; const carouselLabel = document.createElement('span'); carouselLabel.textContent = '하나씩 보기'; carouselLabel.style.cssText = ` font-size: 14px; font-weight: bold; `; carouselCheckbox.addEventListener('change', () => { carouselEnabled = carouselCheckbox.checked; // 네비게이션 버튼 표시/숨김 const displayValue = carouselEnabled ? '' : 'none'; const flexValue = carouselEnabled ? 'flex' : 'none'; prevBtn.style.display = displayValue; nextBtn.style.display = displayValue; numberInputGroup.style.display = flexValue; // 캐러셀 업데이트 updateCarousel(carouselEnabled ? currentIndex : 0); }); carouselToggleContainer.appendChild(carouselCheckbox); carouselToggleContainer.appendChild(carouselLabel); // 이전/다음 버튼 const prevBtn = utils.createButton('◀', { backgroundColor: COLORS.SUCCESS, fontWeight: 'bold', display: 'none' }, () => updateCarousel(getPrevIndex())); const nextBtn = utils.createButton('▶', { backgroundColor: COLORS.SUCCESS, fontWeight: 'bold', display: 'none' }, () => updateCarousel(getNextIndex())); // 탭 활성화 스타일 함수 function setActiveTab(activeButton, inactiveButton) { utils.applyStyles(activeButton, { backgroundColor: COLORS.PRIMARY, fontWeight: 'bold' }); utils.applyStyles(inactiveButton, { backgroundColor: COLORS.SECONDARY, fontWeight: 'normal' }); } // 문제 번호 입력 그룹 생성 const numberInputGroup = utils.createContainer({ display: 'none', alignItems: 'center', gap: '5px', marginLeft: 'auto' }); const numberInput = document.createElement('input'); numberInput.type = 'number'; numberInput.min = '1'; numberInput.placeholder = '번호'; utils.applyStyles(numberInput, { padding: '6px 10px', border: `2px solid ${COLORS.BORDER}`, borderRadius: '4px', fontSize: '14px', width: '70px', outline: 'none' }); numberInput.addEventListener('focus', () => { numberInput.style.borderColor = COLORS.PRIMARY; }); numberInput.addEventListener('blur', () => { numberInput.style.borderColor = COLORS.BORDER; }); const goButton = utils.createButton('이동', { padding: '6px 12px', backgroundColor: COLORS.PRIMARY, fontWeight: 'bold', transition: 'background-color 0.2s', fontSize: '14px' }); goButton.addEventListener('mouseenter', () => { goButton.style.backgroundColor = COLORS.PRIMARY_HOVER; }); goButton.addEventListener('mouseleave', () => { goButton.style.backgroundColor = COLORS.PRIMARY; }); const goToNumber = () => { const number = parseInt(numberInput.value); // Early return: 유효하지 않은 번호이거나 캐러셀이 비활성화된 경우 if (!number || number < 1 || !carouselEnabled) return; updateCarousel(number - 1); }; goButton.addEventListener('click', goToNumber); numberInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { goToNumber(); } }); numberInputGroup.appendChild(numberInput); numberInputGroup.appendChild(goButton); // 전체보기 클릭 이벤트 allTab.addEventListener('click', () => { const existing = document.querySelector(CONSTANTS.SELECTORS.RESULT_SPAN); if (existing) existing.remove(); currentMode = CONSTANTS.MODES.ALL; wrongNumbersList = []; chipButtons = []; carouselStartIndex = CONSTANTS.INDICES.DEFAULT_CAROUSEL_START; updateCarousel(0); setActiveTab(allTab, wrongTab); }); // 틀린문제 보기 클릭 이벤트 wrongTab.addEventListener('click', () => { // 1. 틀린 문제 번호 추출 const wrongNumbers = businessLogic.extractWrongNumbers(listContainer); // 2. 상태 객체 생성 const state = { carouselEnabled, currentIndex }; // 3. 결과 UI 생성 및 삽입 businessLogic.createAndInsertResultUI(listContainer, wrongNumbers, state, updateCarousel, chipButtons); // 4. 모드 설정 currentMode = CONSTANTS.MODES.WRONG; wrongNumbersList = wrongNumbers; carouselStartIndex = CONSTANTS.INDICES.WRONG_MODE_CAROUSEL_START; // 5. 첫 번째 틀린 문제로 이동 (early return 적용된 삼항연산자) const targetIndex = wrongNumbers.length > 0 ? parseInt(wrongNumbers[0]) - 1 : 0; updateCarousel(targetIndex); // 6. 탭 활성화 setActiveTab(wrongTab, allTab); }); // 탭 바에 버튼 추가 tabBar.appendChild(allTab); tabBar.appendChild(wrongTab); tabBar.appendChild(prevBtn); tabBar.appendChild(carouselToggleContainer); tabBar.appendChild(nextBtn); tabBar.appendChild(numberInputGroup); // 리스트의 세 번째 자식으로 탭 바 삽입 if (listContainer.children.length >= 3) { listContainer.insertBefore(tabBar, listContainer.children[2]); } else { listContainer.appendChild(tabBar); } // 초기에는 캐러셀 적용 안 함 (모든 항목 보이기) const allChildren = Array.from(listContainer.children); allChildren.forEach((item) => { item.style.display = ''; }); }); })();