Files
Outcut/content/keyboard.js

464 lines
13 KiB
JavaScript

// 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 };
})();