Zoom Shortcuts

Adds configurable shortcuts for all zoom levels

// ==UserScript==
// @name         Zoom Shortcuts
// @namespace    https://greasyfork.org/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}`));
})();