feat: Gmail-style keyboard navigation and multi-select for message list
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user