// Outlook Relook — Gmail-Style Keyboard Navigation & Multi-Select // Adds keyboard focus cursor and multi-select to OWA's message list. // Gated by the keyboardMultiSelect setting. // // Tracks selections by stable message ID (data attribute or aria-label) // rather than DOM element reference, since OWA frequently re-renders rows. window.OutlookRelook = window.OutlookRelook || {}; window.OutlookRelook.Keyboard = (function () { 'use strict'; var OR = window.OutlookRelook; var currentSettings = {}; var cleanupFns = []; // State — track by ID strings, not DOM references var focusedId = null; // ID of the focused message var selectedIds = new Set(); // Set of selected message IDs var countBadge = null; // --- Message ID extraction --- // OWA messages have various attributes we can use as stable IDs. // Try data-convid, data-itemid, id, or fall back to aria-label. function getMessageId(el) { return el.getAttribute('data-convid') || el.getAttribute('data-itemid') || el.getAttribute('data-tid') || el.getAttribute('id') || el.getAttribute('aria-label') || null; } function getMessageItems() { var items = document.querySelectorAll( '[role="listbox"] [role="option"], [role="list"] [role="listitem"]' ); return Array.from(items); } function findItemById(items, id) { if (!id) return -1; for (var i = 0; i < items.length; i++) { if (getMessageId(items[i]) === id) return i; } return -1; } function isComposeOrDialogActive() { var active = document.activeElement; if (!active) return false; var tag = active.tagName.toLowerCase(); if (tag === 'input' || tag === 'textarea') return true; if (active.getAttribute('contenteditable') === 'true') return true; if (active.closest('[role="dialog"]')) return true; if (active.closest('[role="search"]')) return true; if (active.closest('[aria-label*="compose" i]')) return true; if (active.closest('[aria-label*="New message" i]')) return true; return false; } // --- Visual state application --- // Re-applies focus and selection classes to current DOM elements // based on the ID-based state. Called after every key action and // by the MutationObserver when OWA re-renders. function applyVisualState(items) { // Clear all visual markers first var oldFocused = document.querySelectorAll('.or-kb-focused'); for (var f = 0; f < oldFocused.length; f++) oldFocused[f].classList.remove('or-kb-focused'); var oldSelected = document.querySelectorAll('.or-kb-selected'); for (var s = 0; s < oldSelected.length; s++) oldSelected[s].classList.remove('or-kb-selected'); if (!items) items = getMessageItems(); // Apply focus if (focusedId) { var focusIdx = findItemById(items, focusedId); if (focusIdx >= 0) { items[focusIdx].classList.add('or-kb-focused'); } } // Apply selections selectedIds.forEach(function (id) { var idx = findItemById(items, id); if (idx >= 0) { items[idx].classList.add('or-kb-selected'); console.log('[Outcut] Applied or-kb-selected to', id.substring(0, 20), 'hasClass:', items[idx].classList.contains('or-kb-selected')); } else { console.log('[Outcut] Could not find item for selected id:', id.substring(0, 20)); } }); updateCountBadge(); } // --- Focus management --- function getFocusedIndex(items) { if (!focusedId) return -1; return findItemById(items, focusedId); } function setFocus(items, index) { if (index < 0 || index >= items.length) return; focusedId = getMessageId(items[index]); applyVisualState(items); items[index].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } // --- Selection management --- function toggleSelect(items, index) { if (index < 0 || index >= items.length) return; var id = getMessageId(items[index]); if (!id) return; if (selectedIds.has(id)) { selectedIds.delete(id); } else { selectedIds.add(id); } applyVisualState(items); } function clearSelection() { selectedIds.clear(); var oldSelected = document.querySelectorAll('.or-kb-selected'); for (var i = 0; i < oldSelected.length; i++) oldSelected[i].classList.remove('or-kb-selected'); updateCountBadge(); } function getActionTargets(items) { var targets = []; if (selectedIds.size > 0) { selectedIds.forEach(function (id) { var idx = findItemById(items, id); if (idx >= 0) targets.push(items[idx]); }); } else { var fi = getFocusedIndex(items); if (fi >= 0) targets.push(items[fi]); } return targets; } // --- Selection count badge --- function createCountBadge() { if (!document.body) return null; var badge = document.createElement('div'); badge.className = 'or-kb-selection-count'; badge.style.opacity = '0'; document.body.appendChild(badge); return badge; } function updateCountBadge() { if (!countBadge) countBadge = createCountBadge(); if (!countBadge) return; var count = selectedIds.size; if (count === 0) { countBadge.style.opacity = '0'; } else { countBadge.textContent = count + ' selected'; countBadge.style.opacity = '1'; } } // --- Actions --- // --- Find a button by aria-label, searching within a target element first, // then the global toolbar, then the whole document --- function findButton(target, label) { // 1. Look for inline button within/near the message row // OWA renders Delete/Archive/Flag buttons on each row on hover var btn = target.querySelector('button[aria-label="' + label + '"]'); if (btn) return btn; // 2. Check the parent (some buttons are siblings of the row) if (target.parentElement) { btn = target.parentElement.querySelector('button[aria-label="' + label + '"]'); if (btn) return btn; } // 3. Look in the global toolbar area btn = document.querySelector('[role="toolbar"] button[aria-label="' + label + '"], .fui-Toolbar button[aria-label="' + label + '"]'); if (btn) return btn; // 4. Anywhere in the document btn = document.querySelector('button[aria-label="' + label + '"]'); return btn; } // Simulate hover on a message row to make OWA's inline action buttons appear function triggerHover(target) { var rect = target.getBoundingClientRect(); var enterEvent = new MouseEvent('mouseenter', { bubbles: true, cancelable: true, clientX: rect.x + rect.width - 30, clientY: rect.y + rect.height / 2, }); var overEvent = new MouseEvent('mouseover', { bubbles: true, cancelable: true, clientX: rect.x + rect.width - 30, clientY: rect.y + rect.height / 2, }); target.dispatchEvent(enterEvent); target.dispatchEvent(overEvent); } function performAction(targets, actionFn) { if (targets.length === 0) return; var i = 0; function processNext() { if (i >= targets.length) { clearSelection(); return; } var target = targets[i]; i++; // First, try to find the inline button without clicking the row. // Hover to make OWA's inline action buttons appear. triggerHover(target); setTimeout(function () { // Try inline button first (avoids opening the email in fill-screen mode) var inlineHandled = actionFn(target); // If inline button wasn't found, fall back to clicking the row + toolbar if (inlineHandled === false) { target.click(); setTimeout(function () { actionFn(target); setTimeout(processNext, 200); }, 150); } else { setTimeout(processNext, 200); } }, 100); } processNext(); } function actionDelete(targets) { performAction(targets, function (target) { var btn = findButton(target, 'Delete'); if (btn) { btn.click(); return true; } console.warn('[Outcut] Delete button not found'); return false; }); } function actionArchive(targets) { performAction(targets, function (target) { var btn = findButton(target, 'Archive'); if (btn) { btn.click(); return true; } console.warn('[Outcut] Archive button not found'); return false; }); } function actionMarkReadUnread(targets) { performAction(targets, function (target) { var btn = findButton(target, 'Read / Unread'); if (btn) { btn.click(); return true; } triggerContextMenuAction(target, /mark as read|mark as unread|read \/ unread/i); return true; // context menu handles it }); } function actionMove(targets) { if (targets.length > 0) { triggerHover(targets[0]); setTimeout(function () { var btn = findButton(targets[0], 'Move to'); if (btn) { btn.click(); } else { triggerContextMenuAction(targets[0], /move to/i); } }, 150); } } function actionFlag(targets) { performAction(targets, function (target) { var btn = findButton(target, 'Flag this message') || findButton(target, 'Flag / Unflag'); if (btn) { btn.click(); return true; } console.warn('[Outcut] Flag button not found'); return false; }); } function actionPin(targets) { performAction(targets, function (target) { var btn = findButton(target, 'Pin / Unpin'); if (btn) { btn.click(); return true; } console.warn('[Outcut] Pin button not found'); return false; }); } function triggerContextMenuAction(target, pattern) { var el = target || document.querySelector('.or-kb-focused'); if (!el) return; var rect = el.getBoundingClientRect(); var contextEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: rect.x + 10, clientY: rect.y + 10, }); el.dispatchEvent(contextEvent); setTimeout(function () { var menuItems = document.querySelectorAll('[role="menuitem"]'); for (var j = 0; j < menuItems.length; j++) { if (pattern.test(menuItems[j].textContent || menuItems[j].getAttribute('aria-label') || '')) { menuItems[j].click(); return; } } document.body.click(); }, 300); } // --- Key handler --- // Outlook preset: map Outlook-native keys to the Gmail-style internal keys // used by the switch below. This way the action code stays untouched. function remapForOutlookPreset(key, shift) { // Delete/Backspace → '#' (delete) if (key === 'Delete' || key === 'Backspace') return { key: '#', shift: shift }; // q → mark read/unread (Outlook native shortcut for read/unread) if (key === 'q' && !shift) return { key: 'I', shift: true }; // Insert → flag if (key === 'Insert') return { key: 'f', shift: shift }; return { key: key, shift: shift }; } function handleKeydown(e) { if (!currentSettings.keyboardMultiSelect) return; if (isComposeOrDialogActive()) return; var items = getMessageItems(); if (items.length === 0) return; var key = e.key; var shift = e.shiftKey; // Apply Outlook preset key remapping if active if (currentSettings.keyboardPreset === 'outlook') { var remapped = remapForOutlookPreset(key, shift); key = remapped.key; shift = remapped.shift; } // Initialize focus if not set var currentIdx = getFocusedIndex(items); if (currentIdx < 0) { for (var f = 0; f < items.length; f++) { if (items[f].getAttribute('aria-selected') === 'true' || items[f].classList.contains('is-selected')) { currentIdx = f; focusedId = getMessageId(items[f]); break; } } if (currentIdx < 0) { currentIdx = 0; focusedId = getMessageId(items[0]); } applyVisualState(items); } var handled = true; var targets; switch (key) { case 'j': case 'ArrowDown': if (shift) { toggleSelect(items, currentIdx); if (currentIdx < items.length - 1) { setFocus(items, currentIdx + 1); toggleSelect(items, currentIdx + 1); } } else { if (currentIdx < items.length - 1) { setFocus(items, currentIdx + 1); } } break; case 'k': case 'ArrowUp': if (shift) { toggleSelect(items, currentIdx); if (currentIdx > 0) { setFocus(items, currentIdx - 1); toggleSelect(items, currentIdx - 1); } } else { if (currentIdx > 0) { setFocus(items, currentIdx - 1); } } break; case 'x': case ' ': e.preventDefault(); toggleSelect(items, currentIdx); break; case '#': targets = getActionTargets(items); actionDelete(targets); break; case 'e': targets = getActionTargets(items); actionArchive(targets); break; case 'I': case 'U': // Shift+i or Shift+u — OWA uses a single "Read / Unread" toggle if (shift) { targets = getActionTargets(items); actionMarkReadUnread(targets); } else { handled = false; } break; case 'v': targets = getActionTargets(items); actionMove(targets); break; case 'f': // Flag/unflag targets = getActionTargets(items); actionFlag(targets); break; case 'p': // Pin/unpin targets = getActionTargets(items); actionPin(targets); break; case 'Escape': clearSelection(); break; case 'Enter': case 'o': if (currentIdx >= 0 && currentIdx < items.length) { items[currentIdx].click(); } break; default: handled = false; } if (handled) { e.stopPropagation(); if (key !== 'Enter' && key !== 'o') { e.preventDefault(); } } } // --- Re-apply visual state when OWA re-renders the message list --- function setupListObserver() { var listObserver = new MutationObserver(function () { // Prune IDs that no longer exist in DOM var items = getMessageItems(); var currentIds = new Set(); for (var i = 0; i < items.length; i++) { var id = getMessageId(items[i]); if (id) currentIds.add(id); } selectedIds.forEach(function (id) { if (!currentIds.has(id)) selectedIds.delete(id); }); if (focusedId && !currentIds.has(focusedId)) focusedId = null; // Re-apply classes to new DOM elements applyVisualState(items); }); var checkInterval = setInterval(function () { var list = document.querySelector('[role="listbox"], [role="list"]'); if (list) { clearInterval(checkInterval); listObserver.observe(list, { childList: true, subtree: true }); } }, 1000); cleanupFns.push(function () { clearInterval(checkInterval); listObserver.disconnect(); }); } // --- Public API --- function start(settings) { currentSettings = settings; if (!settings.keyboardMultiSelect) return; document.addEventListener('keydown', handleKeydown, true); setupListObserver(); console.log('[Outcut] Keyboard navigation started'); cleanupFns.push(function () { document.removeEventListener('keydown', handleKeydown, true); }); } function updateSettings(settings) { var wasEnabled = currentSettings.keyboardMultiSelect; var isEnabled = settings.keyboardMultiSelect; currentSettings = settings; if (wasEnabled !== isEnabled) { stop(); start(settings); } } function stop() { for (var i = 0; i < cleanupFns.length; i++) { try { cleanupFns[i](); } catch (e) { /* ignore */ } } cleanupFns = []; clearSelection(); var focused = document.querySelector('.or-kb-focused'); if (focused) focused.classList.remove('or-kb-focused'); focusedId = null; if (countBadge) { countBadge.remove(); countBadge = null; } } return { start: start, updateSettings: updateSettings, stop: stop }; })();