From 311aa0e77198c94dda8a630201b7a4093fd426d4 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Thu, 23 Apr 2026 09:03:29 -0700 Subject: [PATCH] feat: Gmail-style keyboard navigation and multi-select for message list --- content/content.js | 6 + content/keyboard.js | 464 +++++++++++++++++++++++++++++++++++++++++++- themes/base.css | 52 +++++ 3 files changed, 521 insertions(+), 1 deletion(-) diff --git a/content/content.js b/content/content.js index 66552cb..8dfcf33 100644 --- a/content/content.js +++ b/content/content.js @@ -95,6 +95,9 @@ // Apply behavior patches OR.Behavior.start(settings); + // Start keyboard navigation + OR.Keyboard.start(settings); + // Start DOM injector (quick actions) OR.Injector.start(settings); @@ -112,6 +115,9 @@ // Update behavior patches OR.Behavior.updateSettings(updated); + // Update keyboard navigation + OR.Keyboard.updateSettings(updated); + // Update injector OR.Injector.updateSettings(updated); diff --git a/content/keyboard.js b/content/keyboard.js index 213aca5..0bb342b 100644 --- a/content/keyboard.js +++ b/content/keyboard.js @@ -1 +1,463 @@ -// Outlook Relook — keyboard.js (stub, implemented in later task) +// 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. + +window.OutlookRelook = window.OutlookRelook || {}; + +window.OutlookRelook.Keyboard = (function () { + 'use strict'; + + var OR = window.OutlookRelook; + var currentSettings = {}; + var cleanupFns = []; + + // State + var focusedIndex = -1; // Index of the focused message in the list + var selectedSet = new Set(); // Set of selected message DOM elements + var countBadge = null; // Selection count badge element + + // --- Helpers --- + + function getMessageItems() { + // Get all message items from the message list + var items = document.querySelectorAll( + '[role="listbox"] [role="option"], [role="list"] [role="listitem"]' + ); + return Array.from(items); + } + + function isComposeOrDialogActive() { + var active = document.activeElement; + if (!active) return false; + + // Check if focus is in a compose area, search bar, or dialog + 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; + } + + function setFocus(items, index) { + // Remove old focus + var oldFocused = document.querySelector('.or-kb-focused'); + if (oldFocused) oldFocused.classList.remove('or-kb-focused'); + + if (index < 0 || index >= items.length) return; + + focusedIndex = index; + var item = items[index]; + item.classList.add('or-kb-focused'); + + // Scroll into view if needed + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + + function toggleSelect(item) { + if (selectedSet.has(item)) { + selectedSet.delete(item); + item.classList.remove('or-kb-selected'); + } else { + selectedSet.add(item); + item.classList.add('or-kb-selected'); + } + updateCountBadge(); + } + + function clearSelection() { + selectedSet.forEach(function (item) { + item.classList.remove('or-kb-selected'); + }); + selectedSet.clear(); + updateCountBadge(); + } + + function getActionTargets(items) { + // If messages are selected, return those. Otherwise return the focused message. + if (selectedSet.size > 0) { + return Array.from(selectedSet); + } + if (focusedIndex >= 0 && focusedIndex < items.length) { + return [items[focusedIndex]]; + } + return []; + } + + // --- Selection count badge --- + + function createCountBadge() { + 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(); + var count = selectedSet.size; + if (count === 0) { + countBadge.style.opacity = '0'; + } else { + countBadge.textContent = count + ' selected'; + countBadge.style.opacity = '1'; + } + } + + // --- Actions --- + // Each action simulates what a user would do in OWA to perform the operation. + // We click the message to select it in OWA, then trigger the toolbar action. + + function performAction(targets, actionFn) { + if (targets.length === 0) return; + + // Process targets one at a time with a small delay between each + var i = 0; + function processNext() { + if (i >= targets.length) { + // After all targets processed, clear selection + clearSelection(); + return; + } + var target = targets[i]; + i++; + + // Click the message to make it OWA-selected + target.click(); + + // Small delay to let OWA register the selection, then perform action + setTimeout(function () { + actionFn(target); + // Delay before next target + setTimeout(processNext, 150); + }, 100); + } + processNext(); + } + + function actionDelete(targets) { + performAction(targets, function () { + // Try keyboard shortcut first (Delete key) + var deleteEvent = new KeyboardEvent('keydown', { + key: 'Delete', + code: 'Delete', + bubbles: true, + cancelable: true + }); + document.activeElement.dispatchEvent(deleteEvent); + + // Fallback: find and click the delete button in the toolbar + var deleteBtn = document.querySelector( + '[aria-label*="Delete" i][role="button"], [aria-label*="delete" i][role="menuitem"]' + ); + if (deleteBtn) deleteBtn.click(); + }); + } + + function actionArchive(targets) { + performAction(targets, function () { + var archiveBtn = document.querySelector( + '[aria-label*="Archive" i][role="button"], [aria-label*="archive" i][role="menuitem"]' + ); + if (archiveBtn) { + archiveBtn.click(); + } else { + console.warn('[Outlook Relook] Archive button not found'); + } + }); + } + + function actionMarkRead(targets) { + performAction(targets, function () { + var readBtn = document.querySelector( + '[aria-label*="Mark as read" i][role="button"], [aria-label*="Mark as read" i][role="menuitem"]' + ); + if (readBtn) { + readBtn.click(); + } else { + // Try context menu approach + triggerContextMenuAction(/mark as read/i); + } + }); + } + + function actionMarkUnread(targets) { + performAction(targets, function () { + var unreadBtn = document.querySelector( + '[aria-label*="Mark as unread" i][role="button"], [aria-label*="Mark as unread" i][role="menuitem"]' + ); + if (unreadBtn) { + unreadBtn.click(); + } else { + triggerContextMenuAction(/mark as unread/i); + } + }); + } + + function actionMove(targets) { + // For move, we just need to trigger OWA's move dialog on the current selection + if (targets.length > 0) { + // Click the first target to ensure something is selected + targets[0].click(); + + setTimeout(function () { + var moveBtn = document.querySelector( + '[aria-label*="Move to" i][role="button"], [aria-label*="Move" i][role="menuitem"]' + ); + if (moveBtn) { + moveBtn.click(); + } else { + triggerContextMenuAction(/move to/i); + } + }, 100); + } + } + + function triggerContextMenuAction(pattern) { + // Open context menu on the focused/selected element + var focused = document.querySelector('.or-kb-focused'); + if (!focused) return; + + var rect = focused.getBoundingClientRect(); + var contextEvent = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: rect.x + 10, + clientY: rect.y + 10, + }); + focused.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].click(); + return; + } + } + // Close context menu if not found + document.body.click(); + }, 300); + } + + // --- Key handler --- + + function handleKeydown(e) { + // Skip if keyboard mode is off or compose/dialog is active + if (!currentSettings.keyboardMultiSelect) return; + if (isComposeOrDialogActive()) return; + + var items = getMessageItems(); + if (items.length === 0) return; + + var key = e.key; + var shift = e.shiftKey; + + // Initialize focus if not set + if (focusedIndex < 0 || focusedIndex >= items.length) { + // Find the currently OWA-selected item, or start at 0 + for (var f = 0; f < items.length; f++) { + if (items[f].getAttribute('aria-selected') === 'true') { + focusedIndex = f; + break; + } + } + if (focusedIndex < 0) focusedIndex = 0; + } + + var handled = true; + var targets; + + switch (key) { + case 'j': + case 'ArrowDown': + if (shift) { + // Select current and move down + if (focusedIndex >= 0 && focusedIndex < items.length) { + if (!selectedSet.has(items[focusedIndex])) { + toggleSelect(items[focusedIndex]); + } + } + if (focusedIndex < items.length - 1) { + setFocus(items, focusedIndex + 1); + toggleSelect(items[focusedIndex]); + } + } else { + if (focusedIndex < items.length - 1) { + setFocus(items, focusedIndex + 1); + } + } + break; + + case 'k': + case 'ArrowUp': + if (shift) { + // Select current and move up + if (focusedIndex >= 0 && focusedIndex < items.length) { + if (!selectedSet.has(items[focusedIndex])) { + toggleSelect(items[focusedIndex]); + } + } + if (focusedIndex > 0) { + setFocus(items, focusedIndex - 1); + toggleSelect(items[focusedIndex]); + } + } else { + if (focusedIndex > 0) { + setFocus(items, focusedIndex - 1); + } + } + break; + + case 'x': + case ' ': + // Toggle select on focused message + e.preventDefault(); // Prevent page scroll on Space + if (focusedIndex >= 0 && focusedIndex < items.length) { + toggleSelect(items[focusedIndex]); + } + break; + + case '#': + // Delete selected/focused messages + targets = getActionTargets(items); + actionDelete(targets); + break; + + case 'e': + // Archive selected/focused messages + targets = getActionTargets(items); + actionArchive(targets); + break; + + case 'I': + // Shift+i — Mark as read + if (shift) { + targets = getActionTargets(items); + actionMarkRead(targets); + } else { + handled = false; + } + break; + + case 'U': + // Shift+u — Mark as unread + if (shift) { + targets = getActionTargets(items); + actionMarkUnread(targets); + } else { + handled = false; + } + break; + + case 'v': + // Move selected messages + targets = getActionTargets(items); + actionMove(targets); + break; + + case 'Escape': + // Deselect all + clearSelection(); + break; + + case 'Enter': + case 'o': + // Open focused message in reading pane + if (focusedIndex >= 0 && focusedIndex < items.length) { + items[focusedIndex].click(); + } + break; + + default: + handled = false; + } + + if (handled) { + e.stopPropagation(); + // Only preventDefault for keys we handle (except Enter which OWA should also process) + if (key !== 'Enter' && key !== 'o') { + e.preventDefault(); + } + } + } + + // --- Cleanup stale selections when message list re-renders --- + + function setupListObserver() { + var listObserver = new MutationObserver(function () { + // Remove stale entries from selectedSet (elements no longer in DOM) + selectedSet.forEach(function (item) { + if (!document.contains(item)) { + selectedSet.delete(item); + } + }); + updateCountBadge(); + + // Reset focusedIndex if the focused item is gone + var focusedEl = document.querySelector('.or-kb-focused'); + if (!focusedEl || !document.contains(focusedEl)) { + focusedIndex = -1; + } + }); + + // Watch the message list container for child changes + 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('[Outlook Relook] Keyboard navigation started'); + cleanupFns.push(function () { + document.removeEventListener('keydown', handleKeydown, true); + }); + } + + function updateSettings(settings) { + stop(); + currentSettings = settings; + start(settings); + } + + function stop() { + for (var i = 0; i < cleanupFns.length; i++) { + try { cleanupFns[i](); } catch (e) { /* ignore */ } + } + cleanupFns = []; + + // Clean up DOM state + clearSelection(); + var focused = document.querySelector('.or-kb-focused'); + if (focused) focused.classList.remove('or-kb-focused'); + focusedIndex = -1; + + if (countBadge) { + countBadge.remove(); + countBadge = null; + } + } + + return { start: start, updateSettings: updateSettings, stop: stop }; +})(); diff --git a/themes/base.css b/themes/base.css index b7ad9cc..64bfc89 100644 --- a/themes/base.css +++ b/themes/base.css @@ -245,3 +245,55 @@ html[data-or-fontsize="large"] [role="listbox"], html[data-or-fontsize="large"] [role="list"] { font-size: 15px !important; } + + +/* ============================================================ + KEYBOARD NAVIGATION + ============================================================ */ + +/* Focus cursor — the message the keyboard is currently pointing at */ +.or-kb-focused { + outline: 2px solid var(--or-accent, #0078d4) !important; + outline-offset: -2px; + position: relative; + z-index: 1; +} + +/* Selected/checked messages */ +.or-kb-selected { + background-color: rgba(0, 120, 212, 0.08) !important; +} + +html[data-outlook-relook-scheme="dark"] .or-kb-selected { + background-color: rgba(100, 181, 246, 0.12) !important; +} + +/* Selection indicator — small left bar */ +.or-kb-selected::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--or-accent, #0078d4); +} + +/* Selection count badge (injected by keyboard.js) */ +.or-kb-selection-count { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + background: var(--or-bg-primary, #333); + color: var(--or-text-primary, #fff); + border: 1px solid var(--or-border, #555); + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + font-family: system-ui, sans-serif; + z-index: 999998; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + pointer-events: none; + transition: opacity 0.15s; +}