feat: add Gmail-style keyboard navigation to spec and plan
Task 14: keyboard.js with focus cursor, multi-select, and action keys (j/k navigate, x/Space select, # delete, e archive, Shift+i/u read/unread, v move). New keyboardMultiSelect setting and CSS.
This commit is contained in:
@@ -23,6 +23,7 @@ outlook-relook/
|
|||||||
│ ├── observer.js # MutationObserver: suppress elements based on active settings
|
│ ├── observer.js # MutationObserver: suppress elements based on active settings
|
||||||
│ ├── selectors.js # Selector registry: logical name → primary + fallback selectors
|
│ ├── selectors.js # Selector registry: logical name → primary + fallback selectors
|
||||||
│ ├── behavior.js # JS behavior patches (auto-advance, hover suppress, etc.)
|
│ ├── behavior.js # JS behavior patches (auto-advance, hover suppress, etc.)
|
||||||
|
│ ├── keyboard.js # Gmail-style keyboard navigation & multi-select
|
||||||
│ └── injector.js # DOM injection (mark-all-read button, folder jump dialog)
|
│ └── injector.js # DOM injection (mark-all-read button, folder jump dialog)
|
||||||
├── popup/
|
├── popup/
|
||||||
│ ├── popup.html # Settings panel markup
|
│ ├── popup.html # Settings panel markup
|
||||||
@@ -68,6 +69,7 @@ outlook-relook/
|
|||||||
"content/selectors.js",
|
"content/selectors.js",
|
||||||
"content/observer.js",
|
"content/observer.js",
|
||||||
"content/behavior.js",
|
"content/behavior.js",
|
||||||
|
"content/keyboard.js",
|
||||||
"content/injector.js",
|
"content/injector.js",
|
||||||
"content/content.js"
|
"content/content.js"
|
||||||
],
|
],
|
||||||
@@ -241,6 +243,9 @@ window.OutlookRelook.DEFAULTS = {
|
|||||||
autoResizeCompose: true,
|
autoResizeCompose: true,
|
||||||
throttleNotifications: false,
|
throttleNotifications: false,
|
||||||
|
|
||||||
|
// Keyboard Navigation
|
||||||
|
keyboardMultiSelect: true,
|
||||||
|
|
||||||
// Quick Actions
|
// Quick Actions
|
||||||
markAllReadButton: true,
|
markAllReadButton: true,
|
||||||
quickFolderJump: true,
|
quickFolderJump: true,
|
||||||
@@ -2722,6 +2727,20 @@ body::-webkit-scrollbar-thumb {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Keyboard Navigation -->
|
||||||
|
<div class="or-section" data-section="keyboard">
|
||||||
|
<div class="or-section-header">Keyboard Navigation</div>
|
||||||
|
<div class="or-section-body">
|
||||||
|
<div class="or-toggle-row">
|
||||||
|
<label for="keyboardMultiSelect">Gmail-style keyboard multi-select</label>
|
||||||
|
<div class="or-switch"><input type="checkbox" id="keyboardMultiSelect" data-setting="keyboardMultiSelect"><span class="slider"></span></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:#888;padding:4px 0 2px;line-height:1.4;">
|
||||||
|
j/k or arrows to navigate, x/Space to select, # delete, e archive, Shift+i read, Shift+u unread, v move, Esc deselect
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="or-section" data-section="actions">
|
<div class="or-section" data-section="actions">
|
||||||
<div class="or-section-header">Quick Actions</div>
|
<div class="or-section-header">Quick Actions</div>
|
||||||
@@ -2827,6 +2846,7 @@ Handles loading settings into the UI, saving changes on toggle, density preset b
|
|||||||
stickyReplyBar: true,
|
stickyReplyBar: true,
|
||||||
autoResizeCompose: true,
|
autoResizeCompose: true,
|
||||||
throttleNotifications: false,
|
throttleNotifications: false,
|
||||||
|
keyboardMultiSelect: true,
|
||||||
markAllReadButton: true,
|
markAllReadButton: true,
|
||||||
quickFolderJump: true,
|
quickFolderJump: true,
|
||||||
};
|
};
|
||||||
@@ -3214,6 +3234,7 @@ A Chrome extension that reskins Outlook Web App (outlook.office.com) with minima
|
|||||||
- **~40 granular toggles** for density, element hiding, readability, and behavior
|
- **~40 granular toggles** for density, element hiding, readability, and behavior
|
||||||
- **MutationObserver** suppresses dynamically injected clutter (Copilot, banners, suggested replies)
|
- **MutationObserver** suppresses dynamically injected clutter (Copilot, banners, suggested replies)
|
||||||
- **Behavior patches:** auto-collapse ribbon, suppress contact hover cards, auto-advance after delete, toast management
|
- **Behavior patches:** auto-collapse ribbon, suppress contact hover cards, auto-advance after delete, toast management
|
||||||
|
- **Gmail-style keyboard navigation:** j/k to move, x/Space to multi-select, # delete, e archive, Shift+i/u read/unread, v move
|
||||||
- **Quick actions:** "Mark all as read" button, Ctrl+Shift+K folder jump
|
- **Quick actions:** "Mark all as read" button, Ctrl+Shift+K folder jump
|
||||||
- **Settings sync** across Chrome instances via chrome.storage.sync
|
- **Settings sync** across Chrome instances via chrome.storage.sync
|
||||||
- **Export/Import/Reset** settings
|
- **Export/Import/Reset** settings
|
||||||
@@ -3232,6 +3253,20 @@ Click the extension icon to open the settings panel. Changes apply immediately
|
|||||||
|
|
||||||
### Keyboard Shortcuts
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
**Message list (Gmail-style):**
|
||||||
|
- `j` / `Down Arrow` — Next message
|
||||||
|
- `k` / `Up Arrow` — Previous message
|
||||||
|
- `x` / `Space` — Toggle select
|
||||||
|
- `Shift+Down` / `Shift+Up` — Extend selection
|
||||||
|
- `#` — Delete selected
|
||||||
|
- `e` — Archive selected
|
||||||
|
- `Shift+i` — Mark read
|
||||||
|
- `Shift+u` — Mark unread
|
||||||
|
- `v` — Move to folder
|
||||||
|
- `Escape` — Deselect all
|
||||||
|
- `Enter` / `o` — Open message
|
||||||
|
|
||||||
|
**Global:**
|
||||||
- `Ctrl+Shift+K` (or `Cmd+Shift+K` on Mac) — Quick folder jump
|
- `Ctrl+Shift+K` (or `Cmd+Shift+K` on Mac) — Quick folder jump
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -3287,10 +3322,589 @@ git commit -m "docs: README with install, usage, and development guide"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Task 14: Gmail-Style Keyboard Navigation & Multi-Select
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `content/keyboard.js`
|
||||||
|
- Modify: `content/content.js` (wire up Keyboard module)
|
||||||
|
- Modify: `themes/base.css` (add focus/selected styles)
|
||||||
|
|
||||||
|
This is the most complex single feature. It adds a custom focus cursor and multi-select system to OWA's message list, with Gmail-style keyboard shortcuts for bulk actions.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add keyboard focus/select CSS to base.css**
|
||||||
|
|
||||||
|
Append the following to the end of `themes/base.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ============================================================
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create keyboard.js**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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 };
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire keyboard into content.js**
|
||||||
|
|
||||||
|
In `content/content.js`, inside `init()`, after `OR.Behavior.start(settings)` and before `OR.Injector.start(settings)`, add:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Start keyboard navigation
|
||||||
|
OR.Keyboard.start(settings);
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the `chrome.storage.onChanged` listener, after `OR.Behavior.updateSettings(updated)`, add:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Update keyboard navigation
|
||||||
|
OR.Keyboard.updateSettings(updated);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify keyboard navigation**
|
||||||
|
|
||||||
|
1. Reload extension
|
||||||
|
2. Open `outlook.office.com`
|
||||||
|
3. Verify in console: `[Outlook Relook] Keyboard navigation started`
|
||||||
|
4. Click on the message list area, then:
|
||||||
|
- Press `j` — focus cursor (blue outline) should move to the next message
|
||||||
|
- Press `k` — focus cursor should move to the previous message
|
||||||
|
- Press `x` — focused message should get a selected highlight (light blue background + left accent bar)
|
||||||
|
- Press `j`, `x`, `j`, `x` — three messages should now be selected, badge shows "3 selected"
|
||||||
|
- Press `#` — all selected messages should be deleted
|
||||||
|
- Select two messages with `x`, press `e` — should archive them
|
||||||
|
- Press `Shift+i` on a message — should mark as read
|
||||||
|
- Press `Shift+u` on a message — should mark as unread
|
||||||
|
- Press `v` — OWA's "Move to" dialog should open
|
||||||
|
- Press `Escape` — all selections should clear
|
||||||
|
5. Start typing in the search bar — keyboard shortcuts should NOT fire
|
||||||
|
6. Open a compose window — keyboard shortcuts should NOT fire
|
||||||
|
7. Toggle the setting off in the popup — keyboard navigation should stop
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add content/keyboard.js content/content.js themes/base.css
|
||||||
|
git commit -m "feat: Gmail-style keyboard navigation and multi-select for message list"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Self-Review Results
|
## Self-Review Results
|
||||||
|
|
||||||
**Spec coverage:** All spec sections are covered:
|
**Spec coverage:** All spec sections are covered:
|
||||||
- Architecture (Tasks 1-2), Theme System (Tasks 4-6), Selector Strategy (Task 3), Settings Panel (Tasks 10-11), all toggle categories (distributed across Tasks 4, 7, 8, 9), File Structure (Task 1), Development & Testing (Task 12-13). Verified each spec requirement maps to a task.
|
- Architecture (Tasks 1-2), Theme System (Tasks 4-6), Selector Strategy (Task 3), Settings Panel (Tasks 10-11), all toggle categories (distributed across Tasks 4, 7, 8, 9), Keyboard Navigation (Task 14), File Structure (Task 1), Development & Testing (Task 12-13). Verified each spec requirement maps to a task.
|
||||||
|
|
||||||
**Placeholder scan:** No TBDs or TODOs. All code blocks are complete. OWA-specific selectors are noted with a clear strategy (inspect live DOM) rather than left as placeholders.
|
**Placeholder scan:** No TBDs or TODOs. All code blocks are complete. OWA-specific selectors are noted with a clear strategy (inspect live DOM) rather than left as placeholders.
|
||||||
|
|
||||||
@@ -3298,6 +3912,7 @@ git commit -m "docs: README with install, usage, and development guide"
|
|||||||
- `window.OutlookRelook` namespace used consistently
|
- `window.OutlookRelook` namespace used consistently
|
||||||
- `OR.Observer.start/updateSettings/stop` API matches between `observer.js` (Task 7) and `content.js` (Task 7 wiring)
|
- `OR.Observer.start/updateSettings/stop` API matches between `observer.js` (Task 7) and `content.js` (Task 7 wiring)
|
||||||
- `OR.Behavior.start/updateSettings/stop` API matches between `behavior.js` (Task 8) and `content.js` (Task 8 wiring)
|
- `OR.Behavior.start/updateSettings/stop` API matches between `behavior.js` (Task 8) and `content.js` (Task 8 wiring)
|
||||||
|
- `OR.Keyboard.start/updateSettings/stop` API matches between `keyboard.js` (Task 14) and `content.js` (Task 14 wiring)
|
||||||
- `OR.Injector.start/updateSettings/stop` API matches between `injector.js` (Task 9) and `content.js` (Task 9 wiring)
|
- `OR.Injector.start/updateSettings/stop` API matches between `injector.js` (Task 9) and `content.js` (Task 9 wiring)
|
||||||
- `OR.loadSettings`, `OR.saveSettings`, `OR.DEFAULTS`, `OR.DENSITY_PRESETS` match between `settings-defaults.js` (Task 2) and `popup.js` (Task 11, duplicated for popup context)
|
- `OR.loadSettings`, `OR.saveSettings`, `OR.DEFAULTS`, `OR.DENSITY_PRESETS` match between `settings-defaults.js` (Task 2) and `popup.js` (Task 11, duplicated for popup context)
|
||||||
- `OR.resolveSelector`, `OR.resolveSelectorString` match between `selectors.js` (Task 3) and consumers in `observer.js`, `behavior.js`, `injector.js`
|
- `OR.resolveSelector`, `OR.resolveSelectorString` match between `selectors.js` (Task 3) and consumers in `observer.js`, `behavior.js`, `injector.js`
|
||||||
|
|||||||
@@ -175,6 +175,40 @@ Changing the density preset sets all individual density toggles to the preset's
|
|||||||
| Auto-resize compose window | Boolean | On |
|
| Auto-resize compose window | Boolean | On |
|
||||||
| Throttle desktop notifications | Boolean | Off |
|
| Throttle desktop notifications | Boolean | Off |
|
||||||
|
|
||||||
|
### Keyboard Navigation
|
||||||
|
|
||||||
|
Gmail-style keyboard navigation and multi-select for the message list. OWA lacks the ability to select multiple messages and act on them purely from the keyboard — this is the single biggest functionality gap vs Gmail.
|
||||||
|
|
||||||
|
| Toggle | Type | Default |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Keyboard multi-select mode | Boolean | On |
|
||||||
|
|
||||||
|
**Key bindings (active when message list is focused, not during compose):**
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `j` / `Down Arrow` | Move focus to next message |
|
||||||
|
| `k` / `Up Arrow` | Move focus to previous message |
|
||||||
|
| `x` or `Space` | Toggle select on focused message (multi-select) |
|
||||||
|
| `Shift+j` / `Shift+Down` | Select and move to next (extend selection) |
|
||||||
|
| `Shift+k` / `Shift+Up` | Select and move to previous (extend selection) |
|
||||||
|
| `#` | Delete selected message(s) |
|
||||||
|
| `e` | Archive selected message(s) |
|
||||||
|
| `Shift+i` | Mark selected as read |
|
||||||
|
| `Shift+u` | Mark selected as unread |
|
||||||
|
| `v` | Move selected (open OWA's move-to-folder dialog) |
|
||||||
|
| `Escape` | Deselect all |
|
||||||
|
| `Enter` / `o` | Open focused message in reading pane |
|
||||||
|
|
||||||
|
**Implementation approach:**
|
||||||
|
|
||||||
|
- The extension manages its own "focus cursor" on the message list, visually distinguished from OWA's native selection (e.g., a left-border highlight or subtle background tint)
|
||||||
|
- A Set tracks "checked" (multi-selected) message elements, visually marked with a checkbox indicator or distinct background
|
||||||
|
- Action keys (`#`, `e`, `Shift+i`, etc.) iterate over checked messages and dispatch OWA's native actions for each (via toolbar button clicks, context menu triggers, or keyboard shortcut forwarding)
|
||||||
|
- If no messages are checked, actions apply to the currently focused message (single-select behavior, matching Gmail)
|
||||||
|
- All keyboard handling is suppressed when a compose window, search bar, or dialog has focus — detected via `activeElement` checks
|
||||||
|
- A new file `content/keyboard.js` encapsulates all keyboard navigation logic, separate from `behavior.js`
|
||||||
|
|
||||||
### Quick Actions (injected UI)
|
### Quick Actions (injected UI)
|
||||||
|
|
||||||
| Toggle | Type | Default |
|
| Toggle | Type | Default |
|
||||||
@@ -202,6 +236,7 @@ outlook-relook/
|
|||||||
│ ├── observer.js # MutationObserver logic, element suppression
|
│ ├── observer.js # MutationObserver logic, element suppression
|
||||||
│ ├── selectors.js # Selector registry (logical name -> strategies)
|
│ ├── selectors.js # Selector registry (logical name -> strategies)
|
||||||
│ ├── behavior.js # JS behavior tweaks
|
│ ├── behavior.js # JS behavior tweaks
|
||||||
|
│ ├── keyboard.js # Gmail-style keyboard navigation & multi-select
|
||||||
│ └── injector.js # DOM injection (mark-all-read, folder jump)
|
│ └── injector.js # DOM injection (mark-all-read, folder jump)
|
||||||
├── themes/
|
├── themes/
|
||||||
│ ├── base.css # Shared density/spacing/hiding overrides
|
│ ├── base.css # Shared density/spacing/hiding overrides
|
||||||
|
|||||||
Reference in New Issue
Block a user