Last active
April 13, 2025 06:16
-
-
Save lunamoth/5f341c355a3f4b56e57f806d087a125c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name 좌고우면 키보드 페이지 이동 유저스크립트 | |
// @namespace http://tampermonkey.net/ | |
// @version 9.3 | |
// @description 좌우 방향키로 페이지 이동 (Cache 및 주석 개선) | |
// @author Claude & Gemini & lunamoth | |
// @match *://*/* | |
// @grant none | |
// ==/UserScript== | |
(function () { | |
'use strict'; | |
/** | |
* Configuration object - centralized settings for the application | |
*/ | |
const CONFIG = Object.freeze({ | |
cache: { | |
MAX_SIZE: 100, // Max items in URL pattern cache | |
MAX_AGE_MS: 30 * 60 * 1000, // 30 minutes cache item lifetime | |
// Removed ACCESS_THRESHOLD as it's not used in the simplified LRU cache | |
}, | |
navigation: { | |
RESET_DELAY: 150, // Delay before resetting navigation flag after failed attempt | |
MIN_PAGE: 1, // Minimum page number allowed | |
MAX_PAGE: 9999, // Maximum page number allowed | |
DEBOUNCE_DELAY: 100 // Delay for debouncing keydown events to prevent rapid triggers | |
}, | |
observer: { | |
TARGET_SELECTORS: ['nav[aria-label="pagination"]', '.pagination', '#pagination'], // Preferred targets for MutationObserver | |
FALLBACK_TARGET_SELECTORS: ['main', '#main', '#content', 'article'], // Fallback targets if preferred aren't found | |
DEBOUNCE_DELAY: 100, // Debounce delay for MutationObserver callback (cache invalidation) | |
MAX_OBSERVE_TIME: 30 * 1000, // 30 seconds: Stop observer if no changes detected for this long | |
REACTIVATION_INTERVAL: 5 * 60 * 1000, // 5 minutes: Periodically try to reactivate the observer if stopped | |
REACTIVATION_THROTTLE: 1000 // Throttle delay for observer reactivation triggered by user events | |
}, | |
patterns: { | |
// URL patterns are RegExp objects to find page numbers | |
url: [ | |
/[?&]page=(\d{1,4})/i, | |
/[?&]po=(\d{1,4})/i, | |
/[?&]p=(\d{1,4})/i, | |
/page\/(\d{1,4})/i, | |
/\/(\d{1,4})$/i // Matches /<number> at the end of the path | |
], | |
// URL patterns to ignore (e.g., specific content types like Twitter statuses) | |
ignore: [ | |
/\/status\/\d{10,}/i, // Ignore Twitter status URLs | |
/\/\d{10,}/i // Ignore URLs with very long numbers (likely IDs, not pages) | |
] | |
} | |
}); | |
/** | |
* Utility functions | |
*/ | |
const Utils = { | |
/** Debounce function: Delays function execution until after 'wait' ms have elapsed since the last call */ | |
debounce(func, wait) { | |
let timeoutId; | |
const debounced = function (...args) { | |
clearTimeout(timeoutId); | |
timeoutId = setTimeout(() => { | |
func.apply(this, args); | |
}, wait); | |
}; | |
// Add a cancel method to clear any pending execution | |
debounced.cancel = () => { | |
clearTimeout(timeoutId); | |
}; | |
return debounced; | |
}, | |
/** Throttle function: Ensures function is called at most once every 'wait' ms */ | |
throttle(func, wait) { | |
let throttling = false; | |
let lastArgs = null; | |
let timeoutId = null; | |
function throttled(...args) { | |
lastArgs = args; | |
if (!throttling) { | |
throttling = true; | |
func.apply(this, lastArgs); | |
lastArgs = null; // Clear last args after execution | |
// Set timeout to reset throttling flag and potentially call trailing invocation | |
timeoutId = setTimeout(() => { | |
throttling = false; | |
if (lastArgs) { // If called again during throttle period, execute once more after wait | |
throttled.apply(this, lastArgs); | |
} | |
}, wait); | |
} | |
} | |
// Add a cancel method to clear potential trailing call and reset state | |
throttled.cancel = () => { | |
clearTimeout(timeoutId); | |
throttling = false; | |
lastArgs = null; | |
}; | |
return throttled; | |
} | |
}; | |
/** | |
* Simple LRU (Least Recently Used) cache with timestamp tracking for expiration. | |
*/ | |
class Cache { | |
/** | |
* Simple LRU (Least Recently Used) Cache with Max Age. | |
* @param {number} maxSize - Maximum number of items in the cache. | |
*/ | |
constructor(maxSize) { | |
this.maxSize = maxSize; | |
this.cache = new Map(); // Map preserves insertion order, useful for LRU | |
} | |
/** | |
* Retrieves an item from the cache. If found and not expired, | |
* it marks the item as most recently used by moving it to the end of the Map. | |
* @param {string} key - The key of the item to retrieve. | |
* @returns {any | undefined} The cached value or undefined if not found or expired. | |
*/ | |
get(key) { | |
if (!this.cache.has(key)) { | |
return undefined; | |
} | |
const item = this.cache.get(key); | |
const now = Date.now(); | |
// Check for expiration based on MAX_AGE_MS | |
if (now - item.timestamp > CONFIG.cache.MAX_AGE_MS) { | |
this.cache.delete(key); // Remove expired item | |
// console.log(`좌고우면 Cache: Item expired and removed for key: ${key}`); | |
return undefined; | |
} | |
// --- LRU Logic: Mark as most recently used --- | |
// To mark an item as recently used in a Map-based LRU, | |
// we remove it and then re-insert it. Map iteration order is based on insertion order. | |
const value = item.value; | |
this.cache.delete(key); | |
// Re-inserting updates its position to the end (most recent) and refreshes timestamp | |
this.set(key, value); | |
// ------------------------------------------- | |
// console.log(`좌고우면 Cache: Item retrieved for key: ${key}`); | |
return value; // Return the actual cached value | |
} | |
/** | |
* Adds or updates an item in the cache. | |
* If the cache exceeds maxSize, removes the least recently used item (the first item added). | |
* @param {string} key - The key of the item to set. | |
* @param {any} value - The value to cache. | |
*/ | |
set(key, value) { | |
// If key already exists, delete it first. This ensures that when it's re-inserted below, | |
// it moves to the end of the Map, correctly marking it as the most recently used. | |
if (this.cache.has(key)) { | |
this.cache.delete(key); | |
} | |
// If the cache is full, remove the least recently used item. | |
// In a Map, the first item iterated (using keys().next()) is the oldest one. | |
else if (this.cache.size >= this.maxSize) { | |
const leastUsedKey = this.cache.keys().next().value; | |
this.cache.delete(leastUsedKey); | |
// console.log(`좌고우면 Cache: Cache full. Removed least used item with key: ${leastUsedKey}`); | |
} | |
// Add the new item with a fresh timestamp. It will be placed at the end of the Map. | |
this.cache.set(key, { | |
value, | |
timestamp: Date.now(), | |
}); | |
// console.log(`좌고우면 Cache: Item set/updated for key: ${key}`); | |
} | |
/** Clears the entire cache. */ | |
clear() { | |
this.cache.clear(); | |
// console.log("좌고우면 Cache: Cache cleared."); | |
} | |
/** Removes expired items from the cache based on MAX_AGE_MS. */ | |
removeExpired() { | |
const now = Date.now(); | |
let removedCount = 0; | |
for (const [key, item] of this.cache.entries()) { | |
if (now - item.timestamp > CONFIG.cache.MAX_AGE_MS) { | |
this.cache.delete(key); | |
removedCount++; | |
} | |
} | |
// if (removedCount > 0) { | |
// console.log(`좌고우면 Cache: Removed ${removedCount} expired items.`); | |
// } | |
} | |
} | |
/** | |
* Handles URL pattern matching and page number extraction. | |
* Uses a cache to store results for previously analyzed URLs. | |
*/ | |
class UrlManager { | |
constructor() { | |
this.urlCache = new Cache(CONFIG.cache.MAX_SIZE); | |
this.setupCacheCleanup(); // Sets up periodic cleanup of expired cache items | |
} | |
/** Sets up periodic cache cleanup interval */ | |
setupCacheCleanup() { | |
// Use REACTIVATION_INTERVAL for cache cleanup frequency. | |
// Consider a dedicated interval if finer control is needed. | |
this.cleanupInterval = setInterval(() => { | |
this.urlCache.removeExpired(); | |
}, CONFIG.observer.REACTIVATION_INTERVAL); // Same interval as observer reactivation | |
} | |
/** | |
* Finds a matching page number pattern in the given URL. | |
* Checks cache first, then iterates through configured RegExp patterns. | |
* @param {string} url - The URL to check. | |
* @returns {Object|null} - An object { pattern: RegExp, currentPage: number } if found, otherwise null. | |
*/ | |
findPagePattern(url) { | |
const cachedResult = this.urlCache.get(url); | |
if (cachedResult !== undefined) { // Check explicitly for undefined, as null could be cached | |
return cachedResult; | |
} | |
// Iterate through the URL patterns defined in CONFIG | |
for (const pattern of CONFIG.patterns.url) { | |
const match = pattern.exec(url); | |
if (!match || !match[1]) continue; // Ensure a match and a capturing group exist | |
const pageNumber = parseInt(match[1], 10); | |
// Validate the extracted page number | |
if (isNaN(pageNumber) || | |
pageNumber < CONFIG.navigation.MIN_PAGE || | |
pageNumber > CONFIG.navigation.MAX_PAGE) { | |
continue; // Invalid page number | |
} | |
// Found a valid pattern and page number | |
const patternInfo = { | |
pattern: pattern, // The RegExp object itself | |
currentPage: pageNumber | |
}; | |
// Cache the successful result for this URL | |
this.urlCache.set(url, patternInfo); | |
return patternInfo; | |
} | |
// No matching pattern found for this URL, cache null to avoid re-checking | |
this.urlCache.set(url, null); | |
return null; | |
} | |
/** | |
* Updates the page number in a URL based on the matched pattern and direction. | |
* @param {string} url - The current URL. | |
* @param {Object} patternInfo - The result from findPagePattern { pattern: RegExp, currentPage: number }. | |
* @param {number} direction - Navigation direction: 1 for next, -1 for previous. | |
* @returns {string} - The updated URL with the new page number. | |
*/ | |
updatePageInUrl(url, patternInfo, direction) { | |
const { pattern, currentPage } = patternInfo; | |
// Calculate the new page number, clamping it within MIN/MAX bounds | |
const newPage = Math.max( | |
CONFIG.navigation.MIN_PAGE, | |
Math.min(CONFIG.navigation.MAX_PAGE, currentPage + direction) | |
); | |
// If the new page is the same as the current one (e.g., at min/max boundary), return original URL | |
if (newPage === currentPage) { | |
return url; | |
} | |
// Replace the matched page number part in the URL with the new page number. | |
// The callback function receives the full match and captured groups. | |
return url.replace(pattern, (match, capturedPageNumber) => | |
// Replace the captured number (e.g., "123") with the new number string | |
match.replace(capturedPageNumber, newPage.toString()) | |
); | |
} | |
/** | |
* Checks if the URL matches any ignore patterns defined in CONFIG. | |
* @param {string} url - The URL to check. | |
* @returns {boolean} - True if the URL should be ignored, false otherwise. | |
*/ | |
shouldIgnore(url) { | |
return CONFIG.patterns.ignore.some(pattern => pattern.test(url)); | |
} | |
/** Cleans up the interval timer used for cache cleanup. */ | |
cleanup() { | |
clearInterval(this.cleanupInterval); | |
this.urlCache.clear(); // Also clear cache on cleanup | |
} | |
} | |
/** | |
* Handles DOM operations: finding navigation links and observing DOM changes | |
* using an optimized MutationObserver lifecycle. | |
*/ | |
class DomMonitor { | |
constructor() { | |
this.cachedLinks = null; // Cache for rel=prev/next links | |
// Debounced function to invalidate the link cache upon DOM changes | |
this.invalidateCacheDebounced = Utils.debounce(() => { | |
this.cachedLinks = null; | |
// console.log("좌고우면 DOM: Invalidated cached nav links due to DOM change."); | |
}, CONFIG.observer.DEBOUNCE_DELAY); | |
this.isObserving = false; // Flag indicating if the MutationObserver is active | |
this.observer = null; // The MutationObserver instance | |
this.observerTarget = null; // The DOM node being observed | |
this.eventListeners = []; // Stores references to reactivation event listeners | |
this.reactivationInterval = null; // Interval timer for periodic reactivation checks | |
this.stopLifecycleTimer = null; // Timer ID for automatically stopping the observer | |
this.throttledReactivate = null; // Throttled function for observer reactivation | |
this.initializeObserver(); | |
this.setupObserverLifecycle(); // Start the initial observation period | |
} | |
/** Initializes the MutationObserver and finds the target element. */ | |
initializeObserver() { | |
this.findObserverTarget(); // Determine which part of the DOM to observe | |
// Callback function for the MutationObserver | |
const observerCallback = (mutationsList) => { | |
// Only invalidate cache if the observer is supposed to be active | |
if (!this.isObserving) return; | |
// Check if there were actual changes relevant to links (optional optimization) | |
// For simplicity, we invalidate on any observed change within the target subtree. | |
this.invalidateCacheDebounced(); | |
}; | |
this.observer = new MutationObserver(observerCallback); | |
// Start observing immediately after initialization (will be stopped later by lifecycle management) | |
this.startObserver(); | |
} | |
/** Finds the most appropriate DOM element to observe for pagination changes. */ | |
findObserverTarget() { | |
// Try specific, preferred selectors first | |
for (const selector of CONFIG.observer.TARGET_SELECTORS) { | |
this.observerTarget = document.querySelector(selector); | |
if (this.observerTarget) { | |
// console.log(`좌고우면 DOM: Found preferred observer target: ${selector}`); | |
return; | |
} | |
} | |
// If not found, try broader fallback selectors | |
for (const selector of CONFIG.observer.FALLBACK_TARGET_SELECTORS) { | |
this.observerTarget = document.querySelector(selector); | |
if (this.observerTarget) { | |
// console.log(`좌고우면 DOM: Found fallback observer target: ${selector}`); | |
return; | |
} | |
} | |
// As a last resort, observe the entire document body | |
this.observerTarget = document.body; | |
// console.log("좌고우면 DOM: Using document.body as observer target."); | |
} | |
/** Starts the MutationObserver if it's not already running and a target exists. */ | |
startObserver() { | |
if (this.observer && this.observerTarget && !this.isObserving) { | |
try { | |
this.observer.observe(this.observerTarget, { childList: true, subtree: true }); | |
this.isObserving = true; | |
// console.log("좌고우면 DOM: MutationObserver started."); | |
// Clear any pending stop timer from previous lifecycle runs if reactivating | |
if (this.stopLifecycleTimer) { | |
this.stopLifecycleTimer(); | |
this.stopLifecycleTimer = null; // Reset timer handle | |
} | |
// Restart the timer to stop it again after MAX_OBSERVE_TIME | |
this.setupObserverLifecycle(); | |
} catch (error) { | |
console.error("좌고우면: Error starting MutationObserver:", error, "Target:", this.observerTarget); | |
this.isObserving = false; // Ensure state is correct on error | |
} | |
} | |
} | |
/** Stops the MutationObserver if it's currently running. */ | |
stopObserver() { | |
if (this.observer && this.isObserving) { | |
// Cancel any pending debounced cache invalidation before disconnecting | |
if (this.invalidateCacheDebounced && this.invalidateCacheDebounced.cancel) { | |
this.invalidateCacheDebounced.cancel(); | |
} | |
this.observer.disconnect(); | |
this.isObserving = false; | |
// console.log("좌고우면 DOM: MutationObserver stopped."); | |
// Clear the automatic stop timer as we are stopping manually (or it fired) | |
if (this.stopLifecycleTimer) { | |
this.stopLifecycleTimer(); | |
this.stopLifecycleTimer = null; | |
} | |
} | |
} | |
/** Sets up the initial timer to automatically stop the observer after a period. */ | |
setupObserverLifecycle() { | |
// If there's an existing timer, clear it first. | |
if (this.stopLifecycleTimer) { | |
this.stopLifecycleTimer(); | |
} | |
// Automatically stop the observer after MAX_OBSERVE_TIME | |
// to save resources if the page content remains static. | |
const timerId = setTimeout(() => { | |
// console.log(`좌고우면 DOM: Stopping observer due to MAX_OBSERVE_TIME (${CONFIG.observer.MAX_OBSERVE_TIME}ms) reached.`); | |
this.stopObserver(); | |
// After stopping automatically, set up events to reactivate it if the user interacts again. | |
this.setupReactivationEvents(); | |
}, CONFIG.observer.MAX_OBSERVE_TIME); | |
// Store the cancel function for this timer. | |
this.stopLifecycleTimer = () => clearTimeout(timerId); | |
} | |
/** Sets up event listeners and an interval to reactivate the observer if it was stopped. */ | |
setupReactivationEvents() { | |
// Clear existing listeners and interval first to prevent duplicates | |
this.clearReactivationEvents(); | |
// Function to restart the observer (if it's not already running) | |
const reactivateObserver = () => { | |
if (!this.isObserving) { | |
// console.log("좌고우면 DOM: Reactivating observer due to user activity or interval."); | |
this.startObserver(); // This will also reset the MAX_OBSERVE_TIME timer via setupObserverLifecycle | |
// No need for a separate setTimeout to stop here, startObserver handles it. | |
} | |
}; | |
// Throttle the reactivation function to avoid excessive restarts during rapid events (e.g., scrolling). | |
this.throttledReactivate = Utils.throttle( | |
reactivateObserver, | |
CONFIG.observer.REACTIVATION_THROTTLE | |
); | |
// Listen for common user interactions that might signal DOM changes or active usage. | |
const events = ['scroll', 'click', 'keydown']; | |
this.eventListeners = []; // Ensure array is clean before adding new listeners | |
events.forEach(eventType => { | |
// Use the throttled function for event listeners | |
const listener = this.throttledReactivate; | |
window.addEventListener(eventType, listener, { passive: true }); | |
// Store listener details for later removal | |
this.eventListeners.push({ type: eventType, listener }); | |
}); | |
// Also set up a periodic interval as a fallback mechanism to check for reactivation. | |
// This covers cases where DOM might change without direct user input events. | |
this.reactivationInterval = setInterval(() => { | |
// Check if the observer is stopped and needs reactivation. | |
if (!this.isObserving) { | |
// console.log("좌고우면 DOM: Reactivating observer due to interval check."); | |
// Directly call reactivateObserver - throttling less critical for infrequent intervals. | |
reactivateObserver(); | |
} | |
}, CONFIG.observer.REACTIVATION_INTERVAL); | |
} | |
/** Removes event listeners and clears the reactivation interval */ | |
clearReactivationEvents() { | |
clearInterval(this.reactivationInterval); | |
this.reactivationInterval = null; | |
if (this.throttledReactivate && this.throttledReactivate.cancel) { | |
this.throttledReactivate.cancel(); // Cancel any pending throttled calls | |
} | |
this.eventListeners.forEach(({ type, listener }) => { | |
window.removeEventListener(type, listener); | |
}); | |
this.eventListeners = []; // Clear the listeners array | |
} | |
/** Cleans up all resources used by DomMonitor (observer, timers, listeners). */ | |
cleanup() { | |
// console.log("좌고우면 DOM: Cleaning up DomMonitor..."); | |
this.stopObserver(); // Ensure observer is stopped | |
// Clear the automatic stop timer | |
if (this.stopLifecycleTimer) { | |
this.stopLifecycleTimer(); | |
this.stopLifecycleTimer = null; | |
} | |
// Cancel any pending debounced cache invalidation | |
if (this.invalidateCacheDebounced && this.invalidateCacheDebounced.cancel) { | |
this.invalidateCacheDebounced.cancel(); | |
} | |
// Clear reactivation mechanisms | |
this.clearReactivationEvents(); | |
// Clear cached links | |
this.cachedLinks = null; | |
} | |
/** | |
* Checks if the given element is focusable (input field, textarea, etc.). | |
* Used to prevent navigation when user is typing. | |
* @param {Element} element - The DOM element to check. | |
* @returns {boolean} - True if the element is focusable, false otherwise. | |
*/ | |
isFocusable(element) { | |
if (!element) return false; | |
const tagName = element.tagName; | |
return ( | |
tagName === 'INPUT' || | |
tagName === 'TEXTAREA' || | |
tagName === 'SELECT' || | |
element.isContentEditable // Checks contentEditable attribute | |
); | |
} | |
/** | |
* Finds the 'next' and 'previous' navigation links using rel attributes. | |
* Caches the result until the DOM changes. | |
* @returns {Object} - An object { nextLink: HTMLAnchorElement|null, prevLink: HTMLAnchorElement|null }. | |
*/ | |
findNavigationLinks() { | |
// Return cached links if available and valid | |
if (this.cachedLinks) return this.cachedLinks; | |
// Query the DOM for links with rel="next" or rel="prev" | |
const links = document.querySelectorAll('a[rel="next"], a[rel="prev"]'); | |
let nextLink = null; | |
let prevLink = null; | |
// Iterate through found links to find the first match for next and prev | |
for (const link of links) { | |
// Check link validity (e.g., has href) - basic check | |
if (!link.href) continue; | |
if (link.rel === 'next' && !nextLink) nextLink = link; | |
if (link.rel === 'prev' && !prevLink) prevLink = link; | |
// Stop searching once both are found | |
if (nextLink && prevLink) break; | |
} | |
// Cache the found links (even if null) | |
this.cachedLinks = { nextLink, prevLink }; | |
// console.log("좌고우면 DOM: Found nav links:", this.cachedLinks); | |
return this.cachedLinks; | |
} | |
} | |
/** | |
* Main class orchestrating the keyboard navigation functionality. | |
*/ | |
class KeyboardNavigator { | |
constructor() { | |
this.urlManager = new UrlManager(); | |
this.domMonitor = new DomMonitor(); | |
this.isNavigating = false; // Flag to prevent multiple navigations at once | |
// Debounced function to handle key processing after a short delay | |
this.processKeyDebounced = Utils.debounce( | |
this.processKey.bind(this), | |
CONFIG.navigation.DEBOUNCE_DELAY | |
); | |
// Bind event handlers to the class instance | |
this.handleKeydown = this.handleKeydown.bind(this); | |
this.handlePageShow = this.handlePageShow.bind(this); | |
this.handlePageHide = this.handlePageHide.bind(this); | |
this.initialize(); | |
} | |
/** Sets up initial event listeners. */ | |
initialize() { | |
document.addEventListener('keydown', this.handleKeydown); | |
// Use pageshow/pagehide for better handling of back/forward cache (bfcache) | |
window.addEventListener('pageshow', this.handlePageShow); | |
window.addEventListener('pagehide', this.handlePageHide); | |
// console.log("좌고우면: KeyboardNavigator initialized."); | |
} | |
/** Handles the 'pageshow' event, especially for bfcache restores. */ | |
handlePageShow(event) { | |
// event.persisted is true if the page was loaded from bfcache | |
if (event.persisted) { | |
// console.log("좌고우면: Page restored from bfcache. Resetting state."); | |
// Reset navigation flag | |
this.isNavigating = false; | |
// Clear URL cache as context might have changed | |
this.urlManager.urlCache.clear(); | |
// Clear DOM link cache | |
this.domMonitor.cachedLinks = null; | |
// It's safer to re-initialize the DomMonitor as its observer state | |
// and timers might be invalid after bfcache restore. | |
this.domMonitor.cleanup(); | |
this.domMonitor = new DomMonitor(); // Create a fresh instance | |
} | |
} | |
/** Handles the 'pagehide' event, used for cleanup when navigating away. */ | |
handlePageHide(event) { | |
// event.persisted is true if the page is going into bfcache | |
if (!event.persisted) { | |
// console.log("좌고우면: Page is unloading (not entering bfcache). Cleaning up."); | |
// Perform full cleanup if the page is being completely unloaded | |
this.cleanup(); | |
} | |
} | |
/** Handles the 'keydown' event for arrow keys. */ | |
handleKeydown(event) { | |
// Only interested in Left and Right arrow keys | |
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { | |
return; | |
} | |
// Ignore key events if a focusable element has focus (e.g., typing in input) | |
// Pass the event to check for modifier keys if needed in the future | |
if (this.shouldIgnoreKeyEvent(event)) { | |
return; | |
} | |
// Prevent the browser's default action for arrow keys (e.g., scrolling) | |
event.preventDefault(); | |
// Debounce the actual navigation logic | |
this.processKeyDebounced(event); | |
} | |
/** Determines if the key event should be ignored (e.g., focus on input). */ | |
shouldIgnoreKeyEvent(event) { | |
// Add checks for modifier keys here if needed (e.g., event.altKey, event.ctrlKey) | |
// if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { | |
// return true; // Example: ignore if any modifier key is pressed | |
// } | |
const activeEl = document.activeElement; | |
// Check if the currently focused element is an input, textarea, etc. | |
return activeEl && this.domMonitor.isFocusable(activeEl); | |
} | |
/** Processes the debounced keydown event to initiate navigation. */ | |
processKey(event) { | |
// Double-check navigation flag inside the debounced function | |
if (this.isNavigating) return; | |
const direction = event.key === 'ArrowRight' ? 1 : -1; // 1 for next, -1 for previous | |
// console.log(`좌고우면: Processing key: ${event.key}, Direction: ${direction}`); | |
this.navigate(direction); | |
} | |
/** Attempts to navigate to the next or previous page. */ | |
navigate(direction) { | |
// Redundant check for safety | |
if (this.isNavigating) return; | |
const currentUrl = window.location.href; | |
// Check if the current URL should be ignored based on patterns | |
if (this.urlManager.shouldIgnore(currentUrl)) { | |
// console.log(`좌고우면: Ignoring navigation for URL: ${currentUrl}`); | |
return; | |
} | |
// Set navigation flag immediately to prevent concurrent attempts | |
this.isNavigating = true; | |
// Determine the target URL (either by URL pattern or rel links) | |
const targetUrl = this.getTargetUrl(currentUrl, direction); | |
if (targetUrl && targetUrl !== currentUrl) { | |
// console.log(`좌고우면: Navigating to: ${targetUrl}`); | |
window.location.href = targetUrl; | |
// Don't reset isNavigating here. The pageshow/pagehide handlers | |
// will manage the state correctly upon successful navigation or bfcache restore. | |
} else { | |
// console.log("좌고우면: Navigation failed - no target URL found or URL unchanged."); | |
// Reset navigation flag only if navigation didn't proceed | |
this.resetNavigationState(); | |
} | |
} | |
/** | |
* Determines the target URL for navigation. | |
* First tries to find and update page number based on URL patterns. | |
* If that fails, tries to find rel="next"/"prev" links in the DOM. | |
* @param {string} currentUrl - The current page URL. | |
* @param {number} direction - Navigation direction (1 or -1). | |
* @returns {string|null} - The target URL string, or null if no navigation target found. | |
*/ | |
getTargetUrl(currentUrl, direction) { | |
// 1. Try URL pattern matching first | |
const patternInfo = this.urlManager.findPagePattern(currentUrl); | |
if (patternInfo) { | |
const updatedUrl = this.urlManager.updatePageInUrl(currentUrl, patternInfo, direction); | |
// Ensure the URL actually changed (e.g., wasn't already at min/max page) | |
return updatedUrl !== currentUrl ? updatedUrl : null; | |
} | |
// 2. If no URL pattern matched, try finding rel="next"/"prev" links | |
const links = this.domMonitor.findNavigationLinks(); | |
if (direction > 0 && links.nextLink) return links.nextLink.href; | |
if (direction < 0 && links.prevLink) return links.prevLink.href; | |
// 3. No suitable navigation method found | |
return null; | |
} | |
/** Resets the navigation flag after a short delay (used when navigation fails). */ | |
resetNavigationState() { | |
// Use setTimeout to prevent immediate re-triggering if keys are held down, | |
// and allows for potential brief visual feedback if implemented later. | |
setTimeout(() => { | |
this.isNavigating = false; | |
// console.log("좌고우면: Navigation state reset."); | |
}, CONFIG.navigation.RESET_DELAY); | |
} | |
/** Cleans up all event listeners and timers used by the navigator. */ | |
cleanup() { | |
// console.log("좌고우면: Cleaning up KeyboardNavigator..."); | |
document.removeEventListener('keydown', this.handleKeydown); | |
window.removeEventListener('pageshow', this.handlePageShow); | |
window.removeEventListener('pagehide', this.handlePageHide); | |
// Cleanup dependencies | |
this.domMonitor.cleanup(); | |
this.urlManager.cleanup(); | |
// Cancel any pending debounced key processing | |
if (this.processKeyDebounced && this.processKeyDebounced.cancel) { | |
this.processKeyDebounced.cancel(); | |
} | |
// Cancel any pending navigation state reset timer | |
// (Requires storing the timer ID in resetNavigationState, omitted for simplicity currently) | |
} | |
} | |
// === Script Initialization === | |
// Create an instance of the main navigator class to start the script. | |
// The instance is not stored globally, relying on its constructor and event listeners | |
// to keep it active. Cleanup happens via pagehide or potentially script unload. | |
new KeyboardNavigator(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment