Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save lunamoth/5f341c355a3f4b56e57f806d087a125c to your computer and use it in GitHub Desktop.
Save lunamoth/5f341c355a3f4b56e57f806d087a125c to your computer and use it in GitHub Desktop.
// ==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