// Outcut — 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 || {}; // Keyboard shortcut presets var KEY_PRESETS = { gmail: { nextMessage: [{key: 'j'}, {key: 'ArrowDown'}], prevMessage: [{key: 'k'}, {key: 'ArrowUp'}], selectExtendDown: [{key: 'j', shift: true}, {key: 'ArrowDown', shift: true}], selectExtendUp: [{key: 'k', shift: true}, {key: 'ArrowUp', shift: true}], toggleSelect: [{key: 'x'}, {key: ' '}], delete: [{key: '#'}], archive: [{key: 'e'}], readUnread: [{key: 'I', shift: true}, {key: 'U', shift: true}], move: [{key: 'v'}], flag: [{key: 'f'}], pin: [{key: 'p'}], deselect: [{key: 'Escape'}], open: [{key: 'Enter'}, {key: 'o'}], label: 'Gmail-style: j/k nav, x select, # del, e archive' }, outlook: { nextMessage: [{key: 'ArrowDown'}, {key: 'j'}], prevMessage: [{key: 'ArrowUp'}, {key: 'k'}], selectExtendDown: [{key: 'ArrowDown', shift: true}], selectExtendUp: [{key: 'ArrowUp', shift: true}], toggleSelect: [{key: ' '}], delete: [{key: 'Delete'}, {key: 'Backspace'}], archive: [{key: 'e'}], readUnread: [{key: 'q'}, {key: 'I', shift: true}], move: [{key: 'v'}], flag: [{key: 'Insert'}, {key: 'f'}], pin: [{key: 'p'}], deselect: [{key: 'Escape'}], open: [{key: 'Enter'}], label: 'Outlook-style: arrows nav, Space select, Del delete' } }; function matchesAction(e, actionBindings) { if (!actionBindings) return false; for (var i = 0; i < actionBindings.length; i++) { var b = actionBindings[i]; if (e.key === b.key && !!e.shiftKey === !!b.shift && !!e.ctrlKey === !!b.ctrl) { return true; } } return false; } 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'); } }); 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(); } // Returns an array of message IDs to act on. // We return IDs (not DOM elements) because OWA re-renders rows after each // action, invalidating direct element references. function getActionTargetIds(items) { var ids = []; if (selectedIds.size > 0) { selectedIds.forEach(function (id) { ids.push(id); }); } else { if (focusedId) ids.push(focusedId); } return ids; } // Re-resolve an ID to the current DOM element (or null if it's gone). function resolveTargetById(id) { var items = getMessageItems(); var idx = findItemById(items, id); return idx >= 0 ? items[idx] : null; } // --- 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); } // Find a button in the global OWA toolbar (not inline on a row). // The toolbar button acts on OWA's currently selected messages. function findToolbarButton(label) { var sels = [ '[role="toolbar"] button[aria-label="' + label + '"]', '.fui-Toolbar button[aria-label="' + label + '"]', '[aria-label*="Quick actions" i] button[aria-label="' + label + '"]' ]; for (var i = 0; i < sels.length; i++) { var btn = document.querySelector(sels[i]); if (btn) return btn; } // Fallback: any button with that label that's NOT inside a message row var all = document.querySelectorAll('button[aria-label="' + label + '"]'); for (var j = 0; j < all.length; j++) { if (!all[j].closest('[role="option"], [role="listitem"]')) { return all[j]; } } return null; } // Ctrl+click a message row to add it to OWA's native multi-selection. // Uses MouseEvent with ctrlKey + metaKey to handle both Mac and Win. function ctrlClickRow(target) { var rect = target.getBoundingClientRect(); var opts = { bubbles: true, cancelable: true, view: window, clientX: rect.x + 30, clientY: rect.y + rect.height / 2, ctrlKey: true, metaKey: true, button: 0 }; target.dispatchEvent(new MouseEvent('mousedown', opts)); target.dispatchEvent(new MouseEvent('mouseup', opts)); target.dispatchEvent(new MouseEvent('click', opts)); } // Build OWA's native selection by Ctrl+clicking each ID, then run a single // toolbar action that operates on the entire selection. function performToolbarAction(ids, toolbarLabel, fallbackContextPattern) { if (!ids || ids.length === 0) return; // First: clear any current OWA selection by clicking the first target plain var first = resolveTargetById(ids[0]); if (!first) { clearSelection(); return; } first.click(); setTimeout(function () { // Now Ctrl+click the rest to add to selection var i = 1; function addNext() { if (i >= ids.length) { // All selected in OWA, now click the toolbar action button setTimeout(function () { var btn = findToolbarButton(toolbarLabel); if (btn) { btn.click(); } else if (fallbackContextPattern) { var fresh = resolveTargetById(ids[0]); if (fresh) triggerContextMenuAction(fresh, fallbackContextPattern); } else { console.warn('[Outcut] Toolbar button not found: ' + toolbarLabel); } setTimeout(clearSelection, 200); }, 100); return; } var t = resolveTargetById(ids[i]); i++; if (t) ctrlClickRow(t); setTimeout(addNext, 60); } addNext(); }, 100); } function actionDelete(ids) { performToolbarAction(ids, 'Delete', /^delete$/i); } function actionArchive(ids) { performToolbarAction(ids, 'Archive', /^archive$/i); } function actionMarkReadUnread(ids) { performToolbarAction(ids, 'Read / Unread', /mark as read|mark as unread|read \/ unread/i); } function actionMove(ids) { performToolbarAction(ids, 'Move to', /move to/i); } function actionFlag(ids) { // Try multiple labels if (!ids || ids.length === 0) return; var first = resolveTargetById(ids[0]); if (!first) return; first.click(); setTimeout(function () { var i = 1; function addNext() { if (i >= ids.length) { setTimeout(function () { var btn = findToolbarButton('Flag / Unflag') || findToolbarButton('Flag this message'); if (btn) btn.click(); setTimeout(clearSelection, 200); }, 100); return; } var t = resolveTargetById(ids[i]); i++; if (t) ctrlClickRow(t); setTimeout(addNext, 60); } addNext(); }, 100); } function actionPin(ids) { performToolbarAction(ids, 'Pin / Unpin', /pin/i); } 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 --- function handleKeydown(e) { if (!currentSettings.keyboardMultiSelect) return; if (isComposeOrDialogActive()) return; var items = getMessageItems(); if (items.length === 0) return; var presetName = currentSettings.keyboardPreset || 'gmail'; var preset = KEY_PRESETS[presetName] || KEY_PRESETS.gmail; // 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; if (matchesAction(e, preset.selectExtendDown)) { toggleSelect(items, currentIdx); if (currentIdx < items.length - 1) { setFocus(items, currentIdx + 1); toggleSelect(items, currentIdx + 1); } } else if (matchesAction(e, preset.selectExtendUp)) { toggleSelect(items, currentIdx); if (currentIdx > 0) { setFocus(items, currentIdx - 1); toggleSelect(items, currentIdx - 1); } } else if (matchesAction(e, preset.nextMessage)) { if (currentIdx < items.length - 1) setFocus(items, currentIdx + 1); } else if (matchesAction(e, preset.prevMessage)) { if (currentIdx > 0) setFocus(items, currentIdx - 1); } else if (matchesAction(e, preset.toggleSelect)) { e.preventDefault(); toggleSelect(items, currentIdx); } else if (matchesAction(e, preset.delete)) { targets = getActionTargetIds(items); actionDelete(targets); } else if (matchesAction(e, preset.archive)) { targets = getActionTargetIds(items); actionArchive(targets); } else if (matchesAction(e, preset.readUnread)) { targets = getActionTargetIds(items); actionMarkReadUnread(targets); } else if (matchesAction(e, preset.move)) { targets = getActionTargetIds(items); actionMove(targets); } else if (matchesAction(e, preset.flag)) { targets = getActionTargetIds(items); actionFlag(targets); } else if (matchesAction(e, preset.pin)) { targets = getActionTargetIds(items); actionPin(targets); } else if (matchesAction(e, preset.deselect)) { clearSelection(); } else if (matchesAction(e, preset.open)) { if (currentIdx >= 0 && currentIdx < items.length) items[currentIdx].click(); } else { handled = false; } if (handled) { e.stopPropagation(); if (e.key !== 'Enter') 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; var oldPreset = currentSettings.keyboardPreset; var newPreset = settings.keyboardPreset; currentSettings = settings; if (wasEnabled !== isEnabled || oldPreset !== newPreset) { 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 }; })();