您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds configurable shortcuts for all zoom levels
// ==UserScript== // @name Zoom Shortcuts // @namespace https://greatest.deepsurf.us/users/30701-justins83-waze // @version 2025.09.04 // @description Adds configurable shortcuts for all zoom levels // @author JustinS83 // @match *://*.waze.com/*editor* // @exclude *://*.waze.com/user/editor* // @exclude *://*.waze.com/editor/sdk/* // @contributionURL https://github.com/WazeDev/Thank-The-Authors // @grant none // ==/UserScript== (function () { 'use strict'; // ===== CONFIGURATION ===== /** * @typedef {Object} Config * @property {{min:number, max:number}} ZOOM_RANGE - Min/max zoom levels supported. * @property {string} SETTINGS_KEY - LocalStorage key for settings. * @property {string} LOG_PREFIX - Prefix for logging output. * @property {number} SETTINGS_VERSION - Settings schema version. */ const CONFIG = { ZOOM_RANGE: { min: 10, max: 22 }, SETTINGS_KEY: 'WMEZoomShortcuts_Settings', LOG_PREFIX: GM_info.script.name, SETTINGS_VERSION: 1, }; /** * List of supported zoom shortcut descriptors. * @type {Array<{id:string, label:string, level:number}>} */ const ZOOM_SHORTCUTS = Array.from({ length: CONFIG.ZOOM_RANGE.max - CONFIG.ZOOM_RANGE.min + 1 }, (_, i) => { const level = CONFIG.ZOOM_RANGE.min + i; return { id: `zoom${level}`, label: `Zoom to ${level}`, level }; }); /** * Keycode mapping for A-Z and 0-9. * @type {Object.<number, string>} */ const KEYCODE_MAP = Object.fromEntries([...Array.from({ length: 26 }, (_, i) => [65 + i, String.fromCharCode(65 + i)]), ...Array.from({ length: 10 }, (_, i) => [48 + i, String(i)])]); /** * Modifier lookup values for conversion. * @type {Object.<string, number>} */ const MOD_LOOKUP = { C: 1, S: 2, A: 4 }; /** * Modifier flag values for combo-to-display. * @type {Array<{flag:number, char:string}>} */ const MOD_FLAGS = [ { flag: 1, char: 'C' }, { flag: 2, char: 'S' }, { flag: 4, char: 'A' }, ]; /** * Settings structure in-memory. * @type {{version:number, shortcuts: Object.<string, {raw:string|null, combo:string|null}>}} */ let settings = { version: CONFIG.SETTINGS_VERSION, shortcuts: {} }; // ===== LOGGING ===== /** * Simple console logger with prefix. */ const log = { debug: (msg) => console.debug(CONFIG.LOG_PREFIX, msg), error: (msg) => console.error(CONFIG.LOG_PREFIX, msg), }; // ===== KEY CONVERSION ===== /** * Converts a shortcut combo string to raw keycode string for the SDK. * * WHY WE NEED THIS: * The WME SDK is inconsistent in what format it returns for shortcut keys: * - On initial load: returns combo format ("0", "A+X", "CS+K") * - After user changes: returns raw format ("0,48", "4,65", "3,75") * - On page reload: back to combo format again * * To ensure consistency in our storage, we always convert TO raw format * because that's the most reliable format for round-trip storage/retrieval. * * EXAMPLES: * "0" -> "0,48" (single key '0' with no modifiers) * "A+X" -> "4,88" (Alt + X) * "CS+K" -> "3,75" (Ctrl+Shift + K) * "3,75" -> "3,75" (already raw, unchanged) * * @param {string} comboStr - Shortcut string from SDK (format varies!) * @returns {string} Always returns raw format "modifier,keycode" */ function comboToRawKeycodes(comboStr) { if (!comboStr || typeof comboStr !== 'string') return comboStr; // If already in raw form (modifier,keycode), return unchanged if (/^\d+,\d+$/.test(comboStr)) return comboStr; // Handle single digit/letter (no modifiers) - SDK returns just "0" but we need "0,48" if (/^[A-Z0-9]$/.test(comboStr)) { return `0,${comboStr.charCodeAt(0)}`; } // Handle combo format like "A+X", "CS+K", etc. const match = comboStr.match(/^([ACS]+)\+([A-Z0-9])$/); if (!match) return comboStr; const [, modStr, keyStr] = match; const modValue = modStr.split('').reduce((acc, m) => acc | (MOD_LOOKUP[m] || 0), 0); return `${modValue},${keyStr.charCodeAt(0)}`; } /** * Converts raw shortcut keycode to display combo for UI/logging. * * WHY WE NEED THIS: * While we store everything in raw format for consistency, we need human-readable * combo format for: * - Logging/debugging output * - Registering shortcuts with SDK (it accepts combo format) * * This handles the SDK's inconsistent return values by normalizing raw format * back to readable combo format. * * EXAMPLES: * "0,48" -> "0" (just the '0' key) * "4,88" -> "A+X" (Alt + X) * "3,75" -> "CS+K" (Ctrl+Shift + K) * "A+X" -> "A+X" (already combo format, unchanged) * * @param {string} keycodeStr - Raw keycode string "modifier,keycode" or combo format * @returns {string|null} Human-readable combo format or null if no shortcut */ function shortcutKeycodesToCombo(keycodeStr) { if (!keycodeStr || keycodeStr === 'None') return null; // If already in combo form, return unchanged if (/^([ACS]+\+)?[A-Z0-9]$/.test(keycodeStr)) return keycodeStr; // Handle raw format "modifier,keycode" - convert to readable format const parts = keycodeStr.split(','); if (parts.length !== 2) return keycodeStr; const intMod = parseInt(parts[0], 10); const keyNum = parseInt(parts[1], 10); if (isNaN(intMod) || isNaN(keyNum)) return keycodeStr; const modLetters = MOD_FLAGS.filter(({ flag }) => intMod & flag) .map(({ char }) => char) .join(''); const keyChar = KEYCODE_MAP[keyNum] || String(keyNum); // Return just the key if no modifiers, otherwise "MOD+KEY" return modLetters ? `${modLetters}+${keyChar}` : keyChar; } // ===== LEGACY SUPPORT ===== /** * Mapping legacy setting keys to new shortcut IDs. * Only needed once during legacy migration. */ const LEGACY_MAP = { ZoomNew10Shortcut: 'zoom10', ZoomNew11Shortcut: 'zoom11', Zoom0Shortcut: 'zoom12', Zoom1Shortcut: 'zoom13', Zoom2Shortcut: 'zoom14', Zoom3Shortcut: 'zoom15', Zoom4Shortcut: 'zoom16', Zoom5Shortcut: 'zoom17', Zoom6Shortcut: 'zoom18', Zoom7Shortcut: 'zoom19', Zoom8Shortcut: 'zoom20', Zoom9Shortcut: 'zoom21', Zoom10Shortcut: 'zoom22', }; /** * Converts a legacy shortcut value to {raw, combo} structure. * * WHY WE NEED THIS: * Legacy settings could be stored in various formats: * - Just keycode: "90" * - Modifier + keycode: "2,90" * - Combo + keycode: "CS+90" * - Special values: "-1", "None", null * * We normalize all of these to our standard {raw, combo} structure. * * @param {string|number} oldValue - Legacy value, e.g. "2,90", "CS+90", "90", -1 * @returns {{raw:string|null, combo:string|null}} */ function convertLegacyValue(oldValue) { // Handle null, undefined, empty, or disabled values if (!oldValue || oldValue === '-1' || oldValue === 'None' || oldValue === -1) { return { raw: null, combo: null }; } // Convert to string for consistent processing const valueStr = String(oldValue); // Normalize common legacy formats let normalized = valueStr; if (/^\d+$/.test(valueStr)) { // Just keycode like "90" → "0,90" normalized = `0,${valueStr}`; } else if (/^([ACS]+)\+(\d+)$/.test(valueStr)) { // Combo + keycode like "CS+90" → "3,90" const [, modStr, keyStr] = valueStr.match(/^([ACS]+)\+(\d+)$/); const modValue = modStr.split('').reduce((acc, m) => acc | (MOD_LOOKUP[m] || 0), 0); normalized = `${modValue},${keyStr}`; } else if (/^\d+,\d+$/.test(valueStr)) { // Already in raw format like "2,90" - use as-is normalized = valueStr; } else if (/^([ACS]+\+)?[A-Z0-9]$/.test(valueStr)) { // Modern combo format like "CS+Z" or "A" - convert using our standard function normalized = comboToRawKeycodes(valueStr); } // If none match, leave as-is and let shortcutKeycodesToCombo handle it const combo = shortcutKeycodesToCombo(normalized); log.debug(`Legacy conversion: "${oldValue}" → raw:"${normalized}", combo:"${combo}"`); return { raw: normalized, combo }; } /** * Migrates legacy keys in loadedSettings to new format. * * WHY THIS IS NEEDED: * Previous versions stored shortcuts with different key names and formats. * This function maps old setting keys to new shortcut IDs and converts * the values to our normalized {raw, combo} structure. * * EXAMPLE MIGRATION: * "Zoom0Shortcut": "2,90" → settings.shortcuts.zoom12 = {raw: "2,90", combo: "S+Z"} * * @param {Object} loadedSettings - Loaded settings from storage. * @returns {boolean} - True if migration occurred. */ function performMigration(loadedSettings) { log.debug('Performing migration from legacy settings...'); let migrated = false; Object.entries(LEGACY_MAP).forEach(([oldKey, newId]) => { if (loadedSettings[oldKey] !== undefined) { // Only migrate if we don't already have a value for the new key if (!settings.shortcuts[newId] || (settings.shortcuts[newId].raw === null && settings.shortcuts[newId].combo === null)) { const legacyValue = loadedSettings[oldKey]; settings.shortcuts[newId] = convertLegacyValue(legacyValue); log.debug(`Migrated ${oldKey} (${legacyValue}) → ${newId} (${JSON.stringify(settings.shortcuts[newId])})`); migrated = true; } else { log.debug(`Skipped migration of ${oldKey} → ${newId} (already has value)`); } // Clean up legacy key regardless delete loadedSettings[oldKey]; } }); if (migrated) { settings.version = CONFIG.SETTINGS_VERSION; log.debug('Migration completed successfully'); return true; } else { log.debug('No legacy settings found to migrate'); return false; } } // ===== SETTINGS STORAGE ===== /** * Loads settings from localStorage, migrates if legacy, ensures all shortcuts initialized. * * WHY THIS NEEDS TO HANDLE INCONSISTENT DATA: * Due to the SDK's inconsistent return formats, we may have stored bad data before * implementing the normalization fixes. This function now: * 1. Loads existing settings * 2. Normalizes any inconsistent raw/combo pairs * 3. Migrates legacy settings if found * 4. Ensures all shortcuts have proper structure * * FIXES CASES LIKE: * - raw: "0", combo: "0" → raw: "0,48", combo: "0" * - raw: "1", combo: "1" → raw: "0,49", combo: "1" * - Missing combo values, malformed raw values, etc. */ function loadSettingsFromStorage() { try { const stored = localStorage.getItem(CONFIG.SETTINGS_KEY); const loadedSettings = stored ? JSON.parse(stored) : {}; // Copy existing structure settings.shortcuts = loadedSettings.shortcuts || {}; settings.version = loadedSettings.version || 0; let needsSave = false; // Handle version updates and legacy migration if (settings.version < CONFIG.SETTINGS_VERSION) { log.debug(`Settings version ${settings.version} < ${CONFIG.SETTINGS_VERSION}, checking for migration...`); // Look for any legacy keys and migrate them const hasLegacyKeys = Object.keys(loadedSettings).some((key) => key in LEGACY_MAP); if (hasLegacyKeys) { needsSave = performMigration(loadedSettings); } else { settings.version = CONFIG.SETTINGS_VERSION; needsSave = true; log.debug('No legacy settings found, updated version number'); } } else { log.debug('Settings are current version, skipping migration check'); } // Ensure all possible shortcut keys initialized AND normalize any bad data ZOOM_SHORTCUTS.forEach(({ id }) => { if (!settings.shortcuts[id]) { // No existing data - initialize empty settings.shortcuts[id] = { raw: null, combo: null }; } else { // Existing data - validate and normalize it const shortcut = settings.shortcuts[id]; // Check if we have inconsistent/bad raw data (like raw: "0" instead of "0,48") if (shortcut.raw && shortcut.combo) { // We have both raw and combo - let's verify they're consistent const normalizedRaw = comboToRawKeycodes(shortcut.combo); const normalizedCombo = shortcutKeycodesToCombo(shortcut.raw); // If our stored raw doesn't match what the combo should produce, fix it if (shortcut.raw !== normalizedRaw) { log.debug(`Normalizing inconsistent data for ${id}: raw "${shortcut.raw}" → "${normalizedRaw}"`); shortcut.raw = normalizedRaw; needsSave = true; } // If our stored combo doesn't match what the raw should produce, fix it if (shortcut.combo !== normalizedCombo) { log.debug(`Normalizing inconsistent data for ${id}: combo "${shortcut.combo}" → "${normalizedCombo}"`); shortcut.combo = normalizedCombo; needsSave = true; } } else if (shortcut.raw && !shortcut.combo) { // Have raw but missing combo - regenerate combo shortcut.combo = shortcutKeycodesToCombo(shortcut.raw); needsSave = true; log.debug(`Regenerated missing combo for ${id}: "${shortcut.combo}"`); } else if (shortcut.combo && !shortcut.raw) { // Have combo but missing raw - regenerate raw shortcut.raw = comboToRawKeycodes(shortcut.combo); needsSave = true; log.debug(`Regenerated missing raw for ${id}: "${shortcut.raw}"`); } } }); // Save if we made any corrections or migrations if (needsSave) { localStorage.setItem(CONFIG.SETTINGS_KEY, JSON.stringify(settings)); log.debug('Settings saved after normalization/migration'); } } catch (e) { log.error(`Error loading settings: ${e.message}`); // Reset to defaults if corrupted settings = { version: CONFIG.SETTINGS_VERSION, shortcuts: {} }; ZOOM_SHORTCUTS.forEach(({ id }) => { settings.shortcuts[id] = { raw: null, combo: null }; }); // Save the reset defaults try { localStorage.setItem(CONFIG.SETTINGS_KEY, JSON.stringify(settings)); log.debug('Reset to default settings due to corruption'); } catch (saveError) { log.error(`Failed to save reset settings: ${saveError.message}`); } } } /** * Saves current shortcut assignments to localStorage. * * WHY THIS IS COMPLEX: * The WME SDK returns different formats at different times: * 1. Initial page load: combo format ("0", "A+X") * 2. After user changes shortcuts: raw format ("0,48", "4,88") * 3. After page reload: back to combo format * * We normalize everything to raw format for storage consistency, then convert * to combo format for display. This ensures our localStorage always has the * same structure regardless of when this function runs. * * STORAGE FORMAT: * { * "zoom10": { "raw": "0,48", "combo": "0" }, * "zoom20": { "raw": "4,48", "combo": "A+0" } * } * * @param {Object} sdk - WME SDK object */ function saveSettings(sdk) { try { const allShortcuts = sdk.Shortcuts.getAllShortcuts(); allShortcuts.forEach((shortcut) => { if (settings.shortcuts[shortcut.shortcutId]) { const sdkValue = shortcut.shortcutKeys; log.debug(`SDK returned for ${shortcut.shortcutId}: "${sdkValue}" (type: ${typeof sdkValue})`); const raw = comboToRawKeycodes(sdkValue); const combo = shortcutKeycodesToCombo(raw); log.debug(`Converted: raw="${raw}", combo="${combo}"`); settings.shortcuts[shortcut.shortcutId] = { raw, combo }; } }); settings.version = CONFIG.SETTINGS_VERSION; localStorage.setItem(CONFIG.SETTINGS_KEY, JSON.stringify(settings)); log.debug('Settings saved'); } catch (e) { log.error(`Failed to save settings: ${e.message}`); } } // ===== SHORTCUT REGISTRATION ===== /** * Registers all zoom shortcut combos with SDK. * * WHY WE USE COMBO FORMAT HERE: * The SDK's createShortcut() method expects combo format for shortcutKeys: * - Works: shortcutKeys: "A+0" * - Works: shortcutKeys: "0" * - Doesn't work reliably: shortcutKeys: "4,48" * * So we store raw format for consistency but pass combo format to SDK. * We also handle duplicate key errors by resetting conflicting shortcuts. * * @param {Object} sdk - WME SDK instance. */ function registerShortcuts(sdk) { let needsSave = false; ZOOM_SHORTCUTS.forEach(({ id, label, level }) => { try { // SDK expects combo format, not raw format const comboKeys = settings.shortcuts[id]?.combo || null; sdk.Shortcuts.createShortcut({ shortcutId: id, shortcutKeys: comboKeys, // Use combo format for SDK description: label, callback: () => sdk.Map.setZoomLevel({ zoomLevel: level }), }); } catch (e) { // Handle duplicate key conflicts by resetting to no shortcut if (e.message && e.message.includes('already in use')) { log.debug(`Duplicate key detected for ${id}, resetting: ${e.message}`); settings.shortcuts[id] = { raw: null, combo: null }; needsSave = true; // Try to register again with null (no shortcut) try { sdk.Shortcuts.createShortcut({ shortcutId: id, shortcutKeys: null, description: label, callback: () => sdk.Map.setZoomLevel({ zoomLevel: level }), }); log.debug(`Successfully registered ${id} with no shortcut key`); } catch (retryError) { log.error(`Failed to register ${id} even with null keys: ${retryError.message}`); } } else { log.error(`Failed to register ${id}: ${e.message}`); } } }); // Save settings if any duplicates were reset if (needsSave) { try { localStorage.setItem(CONFIG.SETTINGS_KEY, JSON.stringify(settings)); log.debug('Settings saved after resolving duplicate shortcuts'); } catch (saveError) { log.error(`Failed to save settings after duplicate resolution: ${saveError.message}`); } } } // ===== MAIN ENTRYPOINT ===== /** * Initializes the Zoom Shortcuts script. * @param {Object} sdk - SDK instance. */ async function main(sdk) { try { loadSettingsFromStorage(); registerShortcuts(sdk); // Save on tab/window exit window.addEventListener('beforeunload', () => saveSettings(sdk)); /** * Global API exposed for debugging and manual ops. */ window.WMEZoomShortcuts = { /** Returns a shallow copy of current settings. */ settings: () => ({ ...settings }), /** Logs assignments to dev console. */ printCurrentAssignments: () => { ZOOM_SHORTCUTS.forEach(({ id, label }) => { const combo = settings.shortcuts[id]?.combo || '(none)'; log.debug(`${label} (${id}): ${combo}`); }); }, /** Triggers save to localStorage immediately. */ saveSettings: () => saveSettings(sdk), /** Reregisters shortcut keys from settings (for debug). */ reregisterShortcuts: () => registerShortcuts(sdk), /** Returns settings version (for diagnostics). */ getVersion: () => settings.version, }; window.WMEZoomShortcuts.printCurrentAssignments(); log.debug('...initialized'); } catch (e) { log.error(`Initialization failed: ${e.message}`); } } // ===== SDK INTEGRATION & INIT ===== // Block load if SDK isn't present. if (!window.SDK_INITIALIZED) { log.error('SDK_INITIALIZED promise not found'); return; } window.SDK_INITIALIZED.then(() => { try { const sdk = getWmeSdk({ scriptId: 'wme-zoom-shortcuts', scriptName: 'Zoom Shortcuts', }); if (!sdk) throw new Error('Failed to initialize SDK'); main(sdk); } catch (e) { log.error(`SDK initialization failed: ${e.message}`); } }).catch((e) => log.error(`SDK promise rejected: ${e.message}`)); })();